create-ekka-desktop-app 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/cli.js +1 -5
  2. package/package.json +1 -1
  3. package/template/src/demo/DemoApp.tsx +0 -44
  4. package/template/src/demo/layout/Sidebar.tsx +2 -13
  5. package/template/src/demo/pages/LoginPage.tsx +1 -2
  6. package/template/src/demo/pages/SystemPage.tsx +1 -1
  7. package/template/src/ekka/backend/demo.ts +1 -1
  8. package/template/src/ekka/constants.ts +0 -4
  9. package/template/src/ekka/index.ts +0 -2
  10. package/template/src/ekka/internal/backend.ts +8 -8
  11. package/template/src/ekka/ops/index.ts +1 -2
  12. package/template/src/ekka/utils/index.ts +0 -1
  13. package/template/src-tauri/Cargo.toml +2 -0
  14. package/template/src-tauri/crates/ekka-desktop-core/Cargo.toml +30 -0
  15. package/template/src-tauri/crates/ekka-desktop-core/build.rs +42 -0
  16. package/template/src-tauri/crates/ekka-desktop-core/src/bootstrap.rs +39 -0
  17. package/template/src-tauri/crates/ekka-desktop-core/src/config.rs +32 -0
  18. package/template/src-tauri/crates/ekka-desktop-core/src/device_secret.rs +74 -0
  19. package/template/src-tauri/crates/ekka-desktop-core/src/main.rs +1225 -0
  20. package/template/src-tauri/crates/ekka-desktop-core/src/node_credentials.rs +413 -0
  21. package/template/src-tauri/crates/ekka-desktop-core/src/node_vault_crypto.rs +57 -0
  22. package/template/src-tauri/crates/ekka-desktop-core/src/node_vault_store.rs +198 -0
  23. package/template/src-tauri/crates/ekka-desktop-core/src/security_epoch.rs +80 -0
  24. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  25. package/template/src-tauri/src/commands.rs +137 -958
  26. package/template/src-tauri/src/config.rs +4 -44
  27. package/template/src-tauri/src/core_process.rs +335 -0
  28. package/template/src-tauri/src/engine_process.rs +103 -0
  29. package/template/src-tauri/src/handlers/home.rs +1 -32
  30. package/template/src-tauri/src/main.rs +240 -153
  31. package/template/src-tauri/src/node_auth.rs +2 -748
  32. package/template/src-tauri/src/node_credentials.rs +2 -201
  33. package/template/src-tauri/src/node_runner.rs +2 -55
  34. package/template/src-tauri/src/node_vault_crypto.rs +0 -33
  35. package/template/src-tauri/src/node_vault_store.rs +1 -150
  36. package/template/src-tauri/src/ops/mod.rs +0 -2
  37. package/template/src-tauri/src/state.rs +7 -63
  38. package/template/src-tauri/src/types.rs +1 -23
  39. package/template/src-tauri/src/updater.rs +215 -0
  40. package/template/src-tauri/tauri.conf.json +9 -0
  41. package/template/src/demo/pages/DocGenPage.tsx +0 -731
  42. package/template/src/ekka/config.ts +0 -48
  43. package/template/src/ekka/ops/debug.ts +0 -68
  44. package/template/src/ekka/ops/executionRuns.ts +0 -147
  45. package/template/src/ekka/ops/workflowRuns.ts +0 -119
  46. package/template/src/ekka/utils/idempotency.ts +0 -14
  47. package/template/src-tauri/src/ops/home.rs +0 -251
  48. package/template/src-tauri/src/ops/runtime.rs +0 -21
  49. package/template/src-tauri/src/well_known.rs +0 -83
@@ -0,0 +1,1225 @@
1
+ //! EKKA Desktop Core
2
+ //!
3
+ //! Security-critical logic process that communicates via JSON-RPC over stdio.
4
+ //!
5
+ //! # Protocol
6
+ //!
7
+ //! Request (one JSON object per line on stdin):
8
+ //! { "id": "<uuid>", "op": "<string>", "payload": {...} }
9
+ //!
10
+ //! Response (one JSON object per line on stdout):
11
+ //! { "id": "<uuid>", "ok": true|false, "result": {...}|null, "error": {...}|null }
12
+ //!
13
+ //! # Handled Operations
14
+ //!
15
+ //! - nodeCredentials.status
16
+ //! - nodeCredentials.set
17
+ //! - nodeCredentials.clear
18
+ //! - node.auth.authenticate
19
+ //! - runner.taskStats
20
+ //! - wellKnown.fetch
21
+ //! - setup.status
22
+ //! - engine.status
23
+ //! - runner.status
24
+ //! - auth.login
25
+ //! - auth.refresh
26
+ //! - auth.logout
27
+ //! - workflowRuns.create
28
+ //! - workflowRuns.get
29
+ //! - nodeSession.status
30
+ //! - nodeSession.ensureIdentity
31
+ //! - runtime.info
32
+ //! - home.status
33
+ //! - debug.isDevMode
34
+
35
+ mod bootstrap;
36
+ mod config;
37
+ mod device_secret;
38
+ mod node_credentials;
39
+ mod node_vault_crypto;
40
+ mod node_vault_store;
41
+ mod security_epoch;
42
+
43
+ use serde::{Deserialize, Serialize};
44
+ use serde_json::Value;
45
+ use std::io::{self, BufRead, Write};
46
+
47
+ // =============================================================================
48
+ // Protocol Types
49
+ // =============================================================================
50
+
51
+ #[derive(Debug, Deserialize)]
52
+ struct Request {
53
+ id: String,
54
+ op: String,
55
+ #[serde(default)]
56
+ payload: Value,
57
+ }
58
+
59
+ #[derive(Debug, Serialize)]
60
+ struct Response {
61
+ id: String,
62
+ ok: bool,
63
+ #[serde(skip_serializing_if = "Option::is_none")]
64
+ result: Option<Value>,
65
+ #[serde(skip_serializing_if = "Option::is_none")]
66
+ error: Option<ErrorDetail>,
67
+ }
68
+
69
+ #[derive(Debug, Serialize)]
70
+ struct ErrorDetail {
71
+ code: String,
72
+ message: String,
73
+ }
74
+
75
+ impl Response {
76
+ fn ok(id: String, result: Value) -> Self {
77
+ Self {
78
+ id,
79
+ ok: true,
80
+ result: Some(result),
81
+ error: None,
82
+ }
83
+ }
84
+
85
+ fn err(id: String, code: &str, message: &str) -> Self {
86
+ Self {
87
+ id,
88
+ ok: false,
89
+ result: None,
90
+ error: Some(ErrorDetail {
91
+ code: code.to_string(),
92
+ message: message.to_string(),
93
+ }),
94
+ }
95
+ }
96
+ }
97
+
98
+ // =============================================================================
99
+ // Op Dispatch
100
+ // =============================================================================
101
+
102
+ fn dispatch(req: &Request) -> Response {
103
+ match req.op.as_str() {
104
+ "nodeCredentials.status" => handle_credentials_status(&req.id),
105
+ "nodeCredentials.set" => handle_credentials_set(&req.id, &req.payload),
106
+ "nodeCredentials.clear" => handle_credentials_clear(&req.id),
107
+ "node.auth.authenticate" => handle_auth_authenticate(&req.id, &req.payload),
108
+ "runner.taskStats" => handle_runner_task_stats(&req.id),
109
+ "wellKnown.fetch" => handle_well_known_fetch(&req.id),
110
+ "setup.status" => handle_setup_status(&req.id),
111
+ "engine.status" => handle_engine_status(&req.id, &req.payload),
112
+ "runner.status" => handle_runner_status(&req.id, &req.payload),
113
+ "auth.login" => handle_auth_login(&req.id, &req.payload),
114
+ "auth.refresh" => handle_auth_refresh(&req.id, &req.payload),
115
+ "auth.logout" => handle_auth_logout(&req.id, &req.payload),
116
+ "workflowRuns.create" => handle_workflow_runs_create(&req.id, &req.payload),
117
+ "workflowRuns.get" => handle_workflow_runs_get(&req.id, &req.payload),
118
+ "nodeSession.status" => handle_node_session_status(&req.id, &req.payload),
119
+ "nodeSession.ensureIdentity" => handle_ensure_node_identity(&req.id, &req.payload),
120
+ "runtime.info" => handle_runtime_info(&req.id, &req.payload),
121
+ "home.status" => handle_home_status(&req.id, &req.payload),
122
+ "debug.isDevMode" => handle_is_dev_mode(&req.id),
123
+ _ => Response::err(
124
+ req.id.clone(),
125
+ "UNKNOWN_OP",
126
+ &format!("Desktop Core does not handle op: {}", req.op),
127
+ ),
128
+ }
129
+ }
130
+
131
+ // =============================================================================
132
+ // Handlers
133
+ // =============================================================================
134
+
135
+ /// nodeCredentials.status — check if credentials exist, return node_id
136
+ fn handle_credentials_status(id: &str) -> Response {
137
+ tracing::info!(op = "core.nodeCredentials.status", "Handling nodeCredentials.status");
138
+
139
+ let status = node_credentials::get_status();
140
+
141
+ Response::ok(
142
+ id.to_string(),
143
+ serde_json::json!({
144
+ "hasCredentials": status.has_credentials,
145
+ "nodeId": status.node_id,
146
+ }),
147
+ )
148
+ }
149
+
150
+ /// nodeCredentials.set — store node_id + node_secret in vault
151
+ fn handle_credentials_set(id: &str, payload: &Value) -> Response {
152
+ tracing::info!(op = "core.nodeCredentials.set", "Handling nodeCredentials.set");
153
+
154
+ // Extract node_id
155
+ let node_id_str = match payload.get("nodeId").and_then(|v| v.as_str()) {
156
+ Some(id) => id,
157
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "nodeId is required"),
158
+ };
159
+
160
+ // Validate node_id format
161
+ let node_id = match node_credentials::validate_node_id(node_id_str) {
162
+ Ok(id) => id,
163
+ Err(e) => return Response::err(id.to_string(), "INVALID_NODE_ID", &e.to_string()),
164
+ };
165
+
166
+ // Extract node_secret
167
+ let node_secret = match payload.get("nodeSecret").and_then(|v| v.as_str()) {
168
+ Some(secret) => secret,
169
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "nodeSecret is required"),
170
+ };
171
+
172
+ // Validate node_secret
173
+ if let Err(e) = node_credentials::validate_node_secret(node_secret) {
174
+ return Response::err(id.to_string(), "INVALID_NODE_SECRET", &e.to_string());
175
+ }
176
+
177
+ // Store credentials
178
+ match node_credentials::store_credentials(&node_id, node_secret) {
179
+ Ok(()) => {
180
+ tracing::info!(
181
+ op = "core.nodeCredentials.stored",
182
+ node_id = %node_id,
183
+ "Credentials stored successfully"
184
+ );
185
+ Response::ok(
186
+ id.to_string(),
187
+ serde_json::json!({
188
+ "ok": true,
189
+ "nodeId": node_id.to_string(),
190
+ }),
191
+ )
192
+ }
193
+ Err(e) => Response::err(id.to_string(), "CREDENTIALS_STORE_ERROR", &e.to_string()),
194
+ }
195
+ }
196
+
197
+ /// nodeCredentials.clear — remove credentials from vault
198
+ fn handle_credentials_clear(id: &str) -> Response {
199
+ tracing::info!(op = "core.nodeCredentials.clear", "Handling nodeCredentials.clear");
200
+
201
+ match node_credentials::clear_credentials() {
202
+ Ok(()) => Response::ok(
203
+ id.to_string(),
204
+ serde_json::json!({ "ok": true }),
205
+ ),
206
+ Err(e) => Response::err(id.to_string(), "CREDENTIALS_CLEAR_ERROR", &e.to_string()),
207
+ }
208
+ }
209
+
210
+ /// setup.status — check if node credentials are configured (onboarding gate)
211
+ ///
212
+ /// Called by TS on launch to determine if setup wizard is needed.
213
+ /// Reads credential status directly (no RPC hop).
214
+ fn handle_setup_status(id: &str) -> Response {
215
+ tracing::info!(op = "core.setup.status", "Handling setup.status");
216
+
217
+ let status = node_credentials::get_status();
218
+ let node_configured = status.has_credentials;
219
+
220
+ Response::ok(
221
+ id.to_string(),
222
+ serde_json::json!({
223
+ "nodeIdentity": if node_configured { "configured" } else { "not_configured" },
224
+ "setupComplete": node_configured,
225
+ }),
226
+ )
227
+ }
228
+
229
+ /// engine.status — format engine status from host-provided fields
230
+ ///
231
+ /// Host probes the live engine process and passes raw fields as payload.
232
+ /// Core owns the response contract/formatting.
233
+ fn handle_engine_status(id: &str, payload: &Value) -> Response {
234
+ tracing::info!(op = "core.engine.status", "Handling engine.status");
235
+
236
+ // Pass through host-provided fields (Core owns the contract shape)
237
+ let installed = payload.get("installed").and_then(|v| v.as_bool()).unwrap_or(false);
238
+ let running = payload.get("running").and_then(|v| v.as_bool()).unwrap_or(false);
239
+ let available = payload.get("available").and_then(|v| v.as_bool()).unwrap_or(false);
240
+ let pid = payload.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
241
+ let version = payload.get("version").and_then(|v| v.as_str());
242
+ let build = payload.get("build").and_then(|v| v.as_str());
243
+
244
+ Response::ok(
245
+ id.to_string(),
246
+ serde_json::json!({
247
+ "installed": installed,
248
+ "running": running,
249
+ "available": available,
250
+ "pid": pid,
251
+ "version": version,
252
+ "build": build,
253
+ }),
254
+ )
255
+ }
256
+
257
+ /// runner.status — format runner status from host-provided fields
258
+ ///
259
+ /// Host probes the live runner state and passes fields as payload.
260
+ /// Core owns the response contract/formatting.
261
+ fn handle_runner_status(id: &str, payload: &Value) -> Response {
262
+ tracing::info!(op = "core.runner.status", "Handling runner.status");
263
+
264
+ // Pass through host-provided fields (Core owns the contract shape)
265
+ Response::ok(id.to_string(), payload.clone())
266
+ }
267
+
268
+ /// node.auth.authenticate — authenticate with engine using node_secret
269
+ fn handle_auth_authenticate(id: &str, payload: &Value) -> Response {
270
+ tracing::info!(op = "core.node.auth.authenticate", "Handling node.auth.authenticate");
271
+
272
+ // Engine URL from payload or baked config
273
+ let engine_url = payload
274
+ .get("engineUrl")
275
+ .and_then(|v| v.as_str())
276
+ .unwrap_or_else(|| config::engine_url());
277
+
278
+ match node_credentials::authenticate_node(engine_url) {
279
+ Ok(token) => {
280
+ // Populate AUTH_CACHE so runner.taskStats reuses this token
281
+ if let Ok(mut guard) = AUTH_CACHE.lock() {
282
+ *guard = Some(token.clone());
283
+ }
284
+ tracing::info!(
285
+ op = "core.node.auth.success",
286
+ node_id = %token.node_id,
287
+ session_id = %token.session_id,
288
+ "Node authenticated (cached for taskStats)"
289
+ );
290
+ Response::ok(
291
+ id.to_string(),
292
+ serde_json::json!({
293
+ "ok": true,
294
+ "token": token.token,
295
+ "nodeId": token.node_id.to_string(),
296
+ "tenantId": token.tenant_id.to_string(),
297
+ "workspaceId": token.workspace_id.to_string(),
298
+ "sessionId": token.session_id.to_string(),
299
+ "expiresAt": token.expires_at.to_rfc3339(),
300
+ }),
301
+ )
302
+ }
303
+ Err(node_credentials::CredentialsError::AuthFailed(status, ref body)) => {
304
+ let is_secret_err = node_credentials::is_secret_error(status, body);
305
+ let code = if is_secret_err {
306
+ "NODE_SECRET_INVALID"
307
+ } else {
308
+ "NODE_AUTH_FAILED"
309
+ };
310
+ Response::err(id.to_string(), code, &format!("HTTP {}: {}", status, body))
311
+ }
312
+ Err(e) => Response::err(id.to_string(), "NODE_AUTH_ERROR", &e.to_string()),
313
+ }
314
+ }
315
+
316
+ // =============================================================================
317
+ // Runner Stats
318
+ // =============================================================================
319
+
320
+ /// runner.taskStats — fetch runner task stats from engine API (V2)
321
+ ///
322
+ /// Authenticates using node_secret from vault (cached until expiry).
323
+ /// Proxies GET /engine/runner-tasks-v2/stats through Core to avoid CORS.
324
+ /// On 401: clears cache, re-authenticates, retries once.
325
+ fn handle_runner_task_stats(id: &str) -> Response {
326
+ tracing::debug!(op = "core.runner.taskStats", "Handling runner.taskStats");
327
+
328
+ let engine_url = config::engine_url();
329
+
330
+ // Get auth token (cached or fresh)
331
+ let token = match get_cached_or_fresh_token(engine_url) {
332
+ Ok(t) => t,
333
+ Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
334
+ };
335
+
336
+ let client = match reqwest::blocking::Client::builder()
337
+ .timeout(std::time::Duration::from_secs(30))
338
+ .build()
339
+ {
340
+ Ok(c) => c,
341
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
342
+ };
343
+
344
+ let request_id = uuid::Uuid::new_v4().to_string();
345
+
346
+ let send_stats_request =
347
+ |bearer_token: &str| -> Result<reqwest::blocking::Response, reqwest::Error> {
348
+ client
349
+ .get(format!("{}/engine/runner-tasks-v2/stats", engine_url))
350
+ .header("Content-Type", "application/json")
351
+ .header("Authorization", format!("Bearer {}", bearer_token))
352
+ .header("X-EKKA-PROOF-TYPE", "jwt")
353
+ .header("X-REQUEST-ID", &request_id)
354
+ .header("X-EKKA-CORRELATION-ID", &request_id)
355
+ .header("X-EKKA-MODULE", "engine.runner_tasks_v2")
356
+ .header("X-EKKA-ACTION", "stats")
357
+ .header("X-EKKA-CLIENT", config::app_slug())
358
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
359
+ .send()
360
+ };
361
+
362
+ // First attempt
363
+ let response = match send_stats_request(&token.token) {
364
+ Ok(r) => r,
365
+ Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
366
+ };
367
+
368
+ // 401 → clear cache, re-auth, retry once
369
+ if response.status().as_u16() == 401 {
370
+ tracing::info!(op = "core.runner.taskStats.401", "Got 401, clearing cache and retrying");
371
+ clear_auth_cache();
372
+
373
+ let retry_token = match get_cached_or_fresh_token(engine_url) {
374
+ Ok(t) => t,
375
+ Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
376
+ };
377
+
378
+ let retry_response = match send_stats_request(&retry_token.token) {
379
+ Ok(r) => r,
380
+ Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
381
+ };
382
+
383
+ return parse_stats_response(id, retry_response);
384
+ }
385
+
386
+ parse_stats_response(id, response)
387
+ }
388
+
389
+ /// Parse stats HTTP response into a JSON-RPC Response
390
+ fn parse_stats_response(id: &str, resp: reqwest::blocking::Response) -> Response {
391
+ let status = resp.status();
392
+ if status.is_success() {
393
+ match resp.json::<serde_json::Value>() {
394
+ Ok(data) => Response::ok(id.to_string(), data),
395
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
396
+ }
397
+ } else {
398
+ let status_code = status.as_u16();
399
+ let body = resp.text().unwrap_or_default();
400
+ Response::err(
401
+ id.to_string(),
402
+ "HTTP_ERROR",
403
+ &format!("HTTP {}: {}", status_code, body),
404
+ )
405
+ }
406
+ }
407
+
408
+ /// Module-level auth token cache for runner.taskStats
409
+ /// Shared between get_cached_or_fresh_token and clear_auth_cache.
410
+ static AUTH_CACHE: std::sync::Mutex<Option<node_credentials::NodeAuthToken>> =
411
+ std::sync::Mutex::new(None);
412
+
413
+ /// Clear the cached auth token (used after 401 to force re-auth)
414
+ fn clear_auth_cache() {
415
+ if let Ok(mut guard) = AUTH_CACHE.lock() {
416
+ *guard = None;
417
+ tracing::info!(op = "core.auth.cache.cleared", "Auth cache cleared");
418
+ }
419
+ }
420
+
421
+ /// Get a cached auth token or authenticate fresh via node_secret
422
+ fn get_cached_or_fresh_token(engine_url: &str) -> Result<node_credentials::NodeAuthToken, String> {
423
+ // Check cache
424
+ if let Ok(guard) = AUTH_CACHE.lock() {
425
+ if let Some(ref cached) = *guard {
426
+ if cached.expires_at > chrono::Utc::now() + chrono::Duration::seconds(60) {
427
+ tracing::debug!(op = "core.auth.cache.hit", "Using cached auth token");
428
+ return Ok(cached.clone());
429
+ }
430
+ tracing::info!(op = "core.auth.cache.expired", "Cached token near expiry, re-authenticating");
431
+ }
432
+ }
433
+
434
+ // Need to authenticate
435
+ if !node_credentials::has_credentials() {
436
+ return Err("Node not authenticated. Complete setup first.".to_string());
437
+ }
438
+
439
+ let token = node_credentials::authenticate_node(engine_url)
440
+ .map_err(|e| format!("Node authentication failed: {}", e))?;
441
+
442
+ // Cache the token
443
+ if let Ok(mut guard) = AUTH_CACHE.lock() {
444
+ *guard = Some(token.clone());
445
+ }
446
+ tracing::info!(op = "core.auth.cache.stored", "Auth token cached");
447
+
448
+ Ok(token)
449
+ }
450
+
451
+ // =============================================================================
452
+ // Well-Known Configuration
453
+ // =============================================================================
454
+
455
+ /// wellKnown.fetch — fetch grant verification key from engine's public endpoint
456
+ ///
457
+ /// GET /engine/.well-known/ekka-configuration (no auth required)
458
+ /// Returns the grant verification key for cryptographic grant validation.
459
+ fn handle_well_known_fetch(id: &str) -> Response {
460
+ tracing::info!(op = "core.wellKnown.fetch", "Handling wellKnown.fetch");
461
+
462
+ let engine_url = config::engine_url();
463
+ let url = format!(
464
+ "{}/engine/.well-known/ekka-configuration",
465
+ engine_url.trim_end_matches('/')
466
+ );
467
+
468
+ let client = match reqwest::blocking::Client::builder()
469
+ .timeout(std::time::Duration::from_secs(10))
470
+ .build()
471
+ {
472
+ Ok(c) => c,
473
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
474
+ };
475
+
476
+ let response = client
477
+ .get(&url)
478
+ .header("X-EKKA-CLIENT", config::app_slug())
479
+ .send();
480
+
481
+ match response {
482
+ Ok(resp) => {
483
+ let status = resp.status();
484
+ if status.is_success() {
485
+ match resp.json::<serde_json::Value>() {
486
+ Ok(data) => {
487
+ tracing::info!(
488
+ op = "core.wellKnown.fetch.success",
489
+ "Grant verification key fetched successfully"
490
+ );
491
+ Response::ok(id.to_string(), data)
492
+ }
493
+ Err(e) => Response::err(
494
+ id.to_string(),
495
+ "PARSE_ERROR",
496
+ &format!("Failed to parse well-known config: {}", e),
497
+ ),
498
+ }
499
+ } else {
500
+ let status_code = status.as_u16();
501
+ let reason = status.canonical_reason().unwrap_or("Unknown");
502
+ Response::err(
503
+ id.to_string(),
504
+ "HTTP_ERROR",
505
+ &format!("Engine returned error: {} {}", status_code, reason),
506
+ )
507
+ }
508
+ }
509
+ Err(e) => Response::err(
510
+ id.to_string(),
511
+ "REQUEST_FAILED",
512
+ &format!("Failed to fetch well-known config: {}", e),
513
+ ),
514
+ }
515
+ }
516
+
517
+ // =============================================================================
518
+ // Auth Proxy (credential-handling HTTP)
519
+ // =============================================================================
520
+
521
+ /// auth.login — proxy login request to API so credentials never traverse host logic
522
+ ///
523
+ /// POST {engine_url}/auth/login with { identifier, password }
524
+ /// Returns API response verbatim.
525
+ fn handle_auth_login(id: &str, payload: &Value) -> Response {
526
+ tracing::info!(op = "core.auth.login", "Handling auth.login");
527
+
528
+ // Extract credentials
529
+ let identifier = match payload.get("identifier").and_then(|v| v.as_str()) {
530
+ Some(id) => id,
531
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "identifier is required"),
532
+ };
533
+ let password = match payload.get("password").and_then(|v| v.as_str()) {
534
+ Some(p) => p,
535
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "password is required"),
536
+ };
537
+
538
+ let api_url = config::engine_url();
539
+
540
+ let client = match reqwest::blocking::Client::builder()
541
+ .timeout(std::time::Duration::from_secs(30))
542
+ .build()
543
+ {
544
+ Ok(c) => c,
545
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
546
+ };
547
+
548
+ // Security headers (same envelope as host build_security_headers(None, "auth", "login"))
549
+ let request_id = uuid::Uuid::new_v4().to_string();
550
+
551
+ let body = serde_json::json!({
552
+ "identifier": identifier,
553
+ "password": password
554
+ });
555
+
556
+ let response = client
557
+ .post(format!("{}/auth/login", api_url))
558
+ .header("Content-Type", "application/json")
559
+ .header("X-REQUEST-ID", &request_id)
560
+ .header("X-EKKA-CORRELATION-ID", &request_id)
561
+ .header("X-EKKA-PROOF-TYPE", "none")
562
+ .header("X-EKKA-MODULE", "auth")
563
+ .header("X-EKKA-ACTION", "login")
564
+ .header("X-EKKA-CLIENT", config::app_slug())
565
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
566
+ .json(&body)
567
+ .send();
568
+
569
+ match response {
570
+ Ok(resp) => {
571
+ let status = resp.status();
572
+ if status.is_success() {
573
+ match resp.json::<serde_json::Value>() {
574
+ Ok(data) => {
575
+ tracing::info!(op = "core.auth.login.success", "Login succeeded");
576
+ Response::ok(id.to_string(), data)
577
+ }
578
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
579
+ }
580
+ } else {
581
+ let status_code = status.as_u16();
582
+ let body_text = resp.text().unwrap_or_default();
583
+ let error_msg = serde_json::from_str::<Value>(&body_text)
584
+ .ok()
585
+ .and_then(|v| {
586
+ v.get("message")
587
+ .or(v.get("error"))
588
+ .and_then(|m| m.as_str())
589
+ .map(|s| s.to_string())
590
+ })
591
+ .unwrap_or_else(|| format!("HTTP {}", status_code));
592
+ tracing::warn!(
593
+ op = "core.auth.login.failed",
594
+ status = status_code,
595
+ "Login failed: {}",
596
+ error_msg
597
+ );
598
+ Response::err(
599
+ id.to_string(),
600
+ "AUTH_LOGIN_FAILED",
601
+ &format!("HTTP {}: {}", status_code, error_msg),
602
+ )
603
+ }
604
+ }
605
+ Err(e) => Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
606
+ }
607
+ }
608
+
609
+ /// auth.refresh — proxy token refresh to API so refresh_token never traverses host logic
610
+ ///
611
+ /// POST {engine_url}/auth/refresh with { refresh_token }
612
+ /// If jwt is provided, sets proof_type=jwt and Authorization header.
613
+ /// Returns API response verbatim.
614
+ fn handle_auth_refresh(id: &str, payload: &Value) -> Response {
615
+ tracing::info!(op = "core.auth.refresh", "Handling auth.refresh");
616
+
617
+ // Extract refresh token
618
+ let refresh_token = match payload.get("refresh_token").and_then(|v| v.as_str()) {
619
+ Some(t) => t,
620
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "refresh_token is required"),
621
+ };
622
+
623
+ // Extract optional current JWT (for proof_type header)
624
+ let jwt = payload.get("jwt").and_then(|v| v.as_str());
625
+
626
+ let api_url = config::engine_url();
627
+
628
+ let client = match reqwest::blocking::Client::builder()
629
+ .timeout(std::time::Duration::from_secs(30))
630
+ .build()
631
+ {
632
+ Ok(c) => c,
633
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
634
+ };
635
+
636
+ // Security headers (same envelope as host build_security_headers(jwt, "auth", "refresh_token"))
637
+ let request_id = uuid::Uuid::new_v4().to_string();
638
+ let proof_type = if jwt.is_some() { "jwt" } else { "none" };
639
+
640
+ let body = serde_json::json!({
641
+ "refresh_token": refresh_token
642
+ });
643
+
644
+ let mut req_builder = client
645
+ .post(format!("{}/auth/refresh", api_url))
646
+ .header("Content-Type", "application/json")
647
+ .header("X-REQUEST-ID", &request_id)
648
+ .header("X-EKKA-CORRELATION-ID", &request_id)
649
+ .header("X-EKKA-PROOF-TYPE", proof_type)
650
+ .header("X-EKKA-MODULE", "auth")
651
+ .header("X-EKKA-ACTION", "refresh_token")
652
+ .header("X-EKKA-CLIENT", config::app_slug())
653
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0");
654
+
655
+ if let Some(token) = jwt {
656
+ req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
657
+ }
658
+
659
+ let response = req_builder.json(&body).send();
660
+
661
+ match response {
662
+ Ok(resp) => {
663
+ let status = resp.status();
664
+ if status.is_success() {
665
+ match resp.json::<serde_json::Value>() {
666
+ Ok(data) => {
667
+ tracing::info!(op = "core.auth.refresh.success", "Token refresh succeeded");
668
+ Response::ok(id.to_string(), data)
669
+ }
670
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
671
+ }
672
+ } else {
673
+ let status_code = status.as_u16();
674
+ let body_text = resp.text().unwrap_or_default();
675
+ let error_msg = serde_json::from_str::<Value>(&body_text)
676
+ .ok()
677
+ .and_then(|v| {
678
+ v.get("message")
679
+ .or(v.get("error"))
680
+ .and_then(|m| m.as_str())
681
+ .map(|s| s.to_string())
682
+ })
683
+ .unwrap_or_else(|| format!("HTTP {}", status_code));
684
+ tracing::warn!(
685
+ op = "core.auth.refresh.failed",
686
+ status = status_code,
687
+ "Token refresh failed: {}",
688
+ error_msg
689
+ );
690
+ Response::err(
691
+ id.to_string(),
692
+ "AUTH_REFRESH_FAILED",
693
+ &format!("HTTP {}: {}", status_code, error_msg),
694
+ )
695
+ }
696
+ }
697
+ Err(e) => Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
698
+ }
699
+ }
700
+
701
+ /// auth.logout — proxy logout request to API so refresh_token never traverses host logic
702
+ ///
703
+ /// POST {engine_url}/auth/logout with { refresh_token }
704
+ /// Returns API response verbatim.
705
+ fn handle_auth_logout(id: &str, payload: &Value) -> Response {
706
+ tracing::info!(op = "core.auth.logout", "Handling auth.logout");
707
+
708
+ // Extract refresh token
709
+ let refresh_token = match payload.get("refresh_token").and_then(|v| v.as_str()) {
710
+ Some(t) => t,
711
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "refresh_token is required"),
712
+ };
713
+
714
+ let api_url = config::engine_url();
715
+
716
+ let client = match reqwest::blocking::Client::builder()
717
+ .timeout(std::time::Duration::from_secs(30))
718
+ .build()
719
+ {
720
+ Ok(c) => c,
721
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
722
+ };
723
+
724
+ // Security headers (same envelope as host build_security_headers(None, "auth", "logout"))
725
+ let request_id = uuid::Uuid::new_v4().to_string();
726
+
727
+ let body = serde_json::json!({
728
+ "refresh_token": refresh_token
729
+ });
730
+
731
+ let response = client
732
+ .post(format!("{}/auth/logout", api_url))
733
+ .header("Content-Type", "application/json")
734
+ .header("X-REQUEST-ID", &request_id)
735
+ .header("X-EKKA-CORRELATION-ID", &request_id)
736
+ .header("X-EKKA-PROOF-TYPE", "none")
737
+ .header("X-EKKA-MODULE", "auth")
738
+ .header("X-EKKA-ACTION", "logout")
739
+ .header("X-EKKA-CLIENT", config::app_slug())
740
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
741
+ .json(&body)
742
+ .send();
743
+
744
+ match response {
745
+ Ok(resp) => {
746
+ let status = resp.status();
747
+ if status.is_success() {
748
+ match resp.json::<serde_json::Value>() {
749
+ Ok(data) => {
750
+ tracing::info!(op = "core.auth.logout.success", "Logout succeeded");
751
+ Response::ok(id.to_string(), data)
752
+ }
753
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
754
+ }
755
+ } else {
756
+ let status_code = status.as_u16();
757
+ let body_text = resp.text().unwrap_or_default();
758
+ let error_msg = serde_json::from_str::<Value>(&body_text)
759
+ .ok()
760
+ .and_then(|v| {
761
+ v.get("message")
762
+ .or(v.get("error"))
763
+ .and_then(|m| m.as_str())
764
+ .map(|s| s.to_string())
765
+ })
766
+ .unwrap_or_else(|| format!("HTTP {}", status_code));
767
+ tracing::warn!(
768
+ op = "core.auth.logout.failed",
769
+ status = status_code,
770
+ "Logout failed: {}",
771
+ error_msg
772
+ );
773
+ Response::err(
774
+ id.to_string(),
775
+ "AUTH_LOGOUT_FAILED",
776
+ &format!("HTTP {}: {}", status_code, error_msg),
777
+ )
778
+ }
779
+ }
780
+ Err(e) => Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
781
+ }
782
+ }
783
+
784
+ // =============================================================================
785
+ // Runtime Info (host-probes/core-formats)
786
+ // =============================================================================
787
+
788
+ /// runtime.info — format runtime info from host-provided home state
789
+ ///
790
+ /// Host reads home state/path and passes them as payload.
791
+ /// Core owns the response contract/formatting. No FS/vault/network access.
792
+ fn handle_runtime_info(id: &str, payload: &Value) -> Response {
793
+ tracing::info!(op = "core.runtime.info", "Handling runtime.info");
794
+
795
+ let home_state = payload.get("homeState").and_then(|v| v.as_str()).unwrap_or("unknown");
796
+ let home_path = payload.get("homePath").and_then(|v| v.as_str()).unwrap_or("");
797
+
798
+ Response::ok(
799
+ id.to_string(),
800
+ serde_json::json!({
801
+ "runtime": "ekka-bridge",
802
+ "engine_present": true,
803
+ "mode": "engine",
804
+ "homeState": home_state,
805
+ "homePath": home_path,
806
+ }),
807
+ )
808
+ }
809
+
810
+ // =============================================================================
811
+ // Home Status (host-probes/core-formats)
812
+ // =============================================================================
813
+
814
+ /// home.status — format home status from host-provided fields
815
+ ///
816
+ /// Host computes homeState/homePath/grantPresent/reason via SDK.
817
+ /// Core owns the response contract/formatting. No FS/vault/grants access.
818
+ fn handle_home_status(id: &str, payload: &Value) -> Response {
819
+ tracing::info!(op = "core.home.status", "Handling home.status");
820
+
821
+ let state = payload.get("state").and_then(|v| v.as_str()).unwrap_or("BOOTSTRAP_PRE_LOGIN");
822
+ let home_path = payload.get("homePath").and_then(|v| v.as_str()).unwrap_or("");
823
+ let grant_present = payload.get("grantPresent").and_then(|v| v.as_bool()).unwrap_or(false);
824
+ let reason = payload.get("reason").and_then(|v| v.as_str());
825
+
826
+ Response::ok(
827
+ id.to_string(),
828
+ serde_json::json!({
829
+ "state": state,
830
+ "homePath": home_path,
831
+ "grantPresent": grant_present,
832
+ "reason": reason,
833
+ }),
834
+ )
835
+ }
836
+
837
+ // =============================================================================
838
+ // Node Session Status (host-probes/core-formats)
839
+ // =============================================================================
840
+
841
+ /// nodeSession.status — format node session status from host-provided fields
842
+ ///
843
+ /// Host passes session state fields. Core owns the response contract/formatting.
844
+ fn handle_node_session_status(id: &str, payload: &Value) -> Response {
845
+ tracing::info!(op = "core.nodeSession.status", "Handling nodeSession.status");
846
+
847
+ // Host passes pre-computed fields (no secrets)
848
+ let has_session = payload.get("hasSession").and_then(|v| v.as_bool()).unwrap_or(false);
849
+ let session_valid = payload.get("sessionValid").and_then(|v| v.as_bool()).unwrap_or(false);
850
+
851
+ // Session fields (optional, only present if hasSession)
852
+ let session = if has_session {
853
+ let session_id = payload.get("session").and_then(|v| v.get("session_id")).and_then(|v| v.as_str());
854
+ let tenant_id = payload.get("session").and_then(|v| v.get("tenant_id")).and_then(|v| v.as_str());
855
+ let workspace_id = payload.get("session").and_then(|v| v.get("workspace_id")).and_then(|v| v.as_str());
856
+ let expires_at = payload.get("session").and_then(|v| v.get("expires_at")).and_then(|v| v.as_str());
857
+ let is_expired = payload.get("session").and_then(|v| v.get("is_expired")).and_then(|v| v.as_bool()).unwrap_or(false);
858
+ Some(serde_json::json!({
859
+ "session_id": session_id,
860
+ "tenant_id": tenant_id,
861
+ "workspace_id": workspace_id,
862
+ "expires_at": expires_at,
863
+ "is_expired": is_expired,
864
+ }))
865
+ } else {
866
+ None
867
+ };
868
+
869
+ Response::ok(
870
+ id.to_string(),
871
+ serde_json::json!({
872
+ "hasIdentity": false,
873
+ "hasSession": has_session,
874
+ "sessionValid": session_valid,
875
+ "identity": null,
876
+ "session": session,
877
+ }),
878
+ )
879
+ }
880
+
881
+ /// nodeSession.ensureIdentity — verify node identity from host-provided fields
882
+ ///
883
+ /// Host checks if node_auth_token exists and passes fields (no token strings).
884
+ /// If token present: returns success with identity fields.
885
+ /// If token absent: core checks credentials directly and returns appropriate error.
886
+ fn handle_ensure_node_identity(id: &str, payload: &Value) -> Response {
887
+ tracing::info!(op = "core.nodeSession.ensureIdentity", "Handling nodeSession.ensureIdentity");
888
+
889
+ let has_token = payload.get("hasToken").and_then(|v| v.as_bool()).unwrap_or(false);
890
+
891
+ if has_token {
892
+ // Host has a valid node auth token — return identity from provided fields
893
+ let node_id = match payload.get("nodeId").and_then(|v| v.as_str()) {
894
+ Some(nid) => nid,
895
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "nodeId is required when hasToken is true"),
896
+ };
897
+ let tenant_id = payload.get("tenantId").and_then(|v| v.as_str()).unwrap_or("");
898
+ let workspace_id = payload.get("workspaceId").and_then(|v| v.as_str()).unwrap_or("");
899
+
900
+ return Response::ok(
901
+ id.to_string(),
902
+ serde_json::json!({
903
+ "ok": true,
904
+ "node_id": node_id,
905
+ "tenant_id": tenant_id,
906
+ "workspace_id": workspace_id,
907
+ "auth_method": "node_secret"
908
+ }),
909
+ );
910
+ }
911
+
912
+ // No token — check if credentials exist (core has direct access)
913
+ let status = node_credentials::get_status();
914
+
915
+ if status.has_credentials {
916
+ // Credentials exist but auth failed or not attempted
917
+ Response::err(
918
+ id.to_string(),
919
+ "NODE_NOT_AUTHENTICATED",
920
+ "Node credentials exist but not authenticated. Restart app to authenticate.",
921
+ )
922
+ } else {
923
+ // No credentials configured
924
+ Response::err(
925
+ id.to_string(),
926
+ "NODE_CREDENTIALS_MISSING",
927
+ "Node credentials not configured. Use nodeCredentials.set to configure.",
928
+ )
929
+ }
930
+ }
931
+
932
+ // =============================================================================
933
+ // Workflow Runs (proxied HTTP)
934
+ // =============================================================================
935
+
936
+ /// workflowRuns.create — proxy workflow run creation to engine API
937
+ ///
938
+ /// POST {engine_url}/engine/workflow-runs with the request body.
939
+ /// If jwt is provided, sets proof_type=jwt and Authorization header.
940
+ /// Returns API response verbatim.
941
+ fn handle_workflow_runs_create(id: &str, payload: &Value) -> Response {
942
+ tracing::info!(op = "core.workflowRuns.create", "Handling workflowRuns.create");
943
+
944
+ // Extract request body
945
+ let request = match payload.get("request") {
946
+ Some(r) => r.clone(),
947
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "request is required"),
948
+ };
949
+
950
+ // Extract optional JWT
951
+ let jwt = payload.get("jwt").and_then(|v| v.as_str());
952
+
953
+ let engine_url = config::engine_url();
954
+
955
+ let client = match reqwest::blocking::Client::builder()
956
+ .timeout(std::time::Duration::from_secs(30))
957
+ .build()
958
+ {
959
+ Ok(c) => c,
960
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
961
+ };
962
+
963
+ // Security headers (same as host build_security_headers(jwt, "desktop.docgen", "workflow.create"))
964
+ let request_id = uuid::Uuid::new_v4().to_string();
965
+ let proof_type = if jwt.is_some() { "jwt" } else { "none" };
966
+
967
+ let mut req_builder = client
968
+ .post(format!("{}/engine/workflow-runs", engine_url))
969
+ .header("Content-Type", "application/json")
970
+ .header("X-REQUEST-ID", &request_id)
971
+ .header("X-EKKA-CORRELATION-ID", &request_id)
972
+ .header("X-EKKA-PROOF-TYPE", proof_type)
973
+ .header("X-EKKA-MODULE", "desktop.docgen")
974
+ .header("X-EKKA-ACTION", "workflow.create")
975
+ .header("X-EKKA-CLIENT", config::app_slug())
976
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0");
977
+
978
+ if let Some(token) = jwt {
979
+ req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
980
+ }
981
+
982
+ let response = req_builder.json(&request).send();
983
+
984
+ match response {
985
+ Ok(resp) => {
986
+ let status = resp.status();
987
+ if status.is_success() {
988
+ match resp.json::<serde_json::Value>() {
989
+ Ok(data) => {
990
+ tracing::info!(op = "core.workflowRuns.create.success", "Workflow run created");
991
+ Response::ok(id.to_string(), data)
992
+ }
993
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
994
+ }
995
+ } else {
996
+ let status_code = status.as_u16();
997
+ let body_text = resp.text().unwrap_or_default();
998
+ let error_msg = serde_json::from_str::<Value>(&body_text)
999
+ .ok()
1000
+ .and_then(|v| {
1001
+ v.get("message")
1002
+ .or(v.get("error"))
1003
+ .and_then(|m| m.as_str())
1004
+ .map(|s| s.to_string())
1005
+ })
1006
+ .unwrap_or_else(|| format!("HTTP {}", status_code));
1007
+ tracing::warn!(
1008
+ op = "core.workflowRuns.create.failed",
1009
+ status = status_code,
1010
+ "Workflow run creation failed: {}",
1011
+ error_msg
1012
+ );
1013
+ Response::err(
1014
+ id.to_string(),
1015
+ "WORKFLOW_RUN_CREATE_FAILED",
1016
+ &format!("HTTP {}: {}", status_code, error_msg),
1017
+ )
1018
+ }
1019
+ }
1020
+ Err(e) => {
1021
+ if e.is_connect() {
1022
+ Response::err(
1023
+ id.to_string(),
1024
+ "ENGINE_UNAVAILABLE",
1025
+ &format!("Cannot connect to engine at {}. Is the engine running?", engine_url),
1026
+ )
1027
+ } else {
1028
+ Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string())
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ /// workflowRuns.get — proxy workflow run fetch from engine API
1035
+ ///
1036
+ /// GET {engine_url}/engine/workflow-runs/{id}
1037
+ /// If jwt is provided, sets proof_type=jwt and Authorization header.
1038
+ /// Returns API response verbatim.
1039
+ fn handle_workflow_runs_get(id: &str, payload: &Value) -> Response {
1040
+ tracing::info!(op = "core.workflowRuns.get", "Handling workflowRuns.get");
1041
+
1042
+ // Extract workflow run ID
1043
+ let run_id = match payload.get("id").and_then(|v| v.as_str()) {
1044
+ Some(rid) => rid,
1045
+ None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "id is required"),
1046
+ };
1047
+
1048
+ // Extract optional JWT
1049
+ let jwt = payload.get("jwt").and_then(|v| v.as_str());
1050
+
1051
+ let engine_url = config::engine_url();
1052
+
1053
+ let client = match reqwest::blocking::Client::builder()
1054
+ .timeout(std::time::Duration::from_secs(30))
1055
+ .build()
1056
+ {
1057
+ Ok(c) => c,
1058
+ Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
1059
+ };
1060
+
1061
+ // Security headers (same as host build_security_headers(jwt, "desktop.docgen", "workflow.get"))
1062
+ let request_id = uuid::Uuid::new_v4().to_string();
1063
+ let proof_type = if jwt.is_some() { "jwt" } else { "none" };
1064
+
1065
+ let mut req_builder = client
1066
+ .get(format!("{}/engine/workflow-runs/{}", engine_url, run_id))
1067
+ .header("Content-Type", "application/json")
1068
+ .header("X-REQUEST-ID", &request_id)
1069
+ .header("X-EKKA-CORRELATION-ID", &request_id)
1070
+ .header("X-EKKA-PROOF-TYPE", proof_type)
1071
+ .header("X-EKKA-MODULE", "desktop.docgen")
1072
+ .header("X-EKKA-ACTION", "workflow.get")
1073
+ .header("X-EKKA-CLIENT", config::app_slug())
1074
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0");
1075
+
1076
+ if let Some(token) = jwt {
1077
+ req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
1078
+ }
1079
+
1080
+ let response = req_builder.send();
1081
+
1082
+ match response {
1083
+ Ok(resp) => {
1084
+ let status = resp.status();
1085
+ if status.is_success() {
1086
+ match resp.json::<serde_json::Value>() {
1087
+ Ok(data) => {
1088
+ tracing::info!(op = "core.workflowRuns.get.success", "Workflow run fetched");
1089
+ Response::ok(id.to_string(), data)
1090
+ }
1091
+ Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
1092
+ }
1093
+ } else {
1094
+ let status_code = status.as_u16();
1095
+ let body_text = resp.text().unwrap_or_default();
1096
+ let error_msg = serde_json::from_str::<Value>(&body_text)
1097
+ .ok()
1098
+ .and_then(|v| {
1099
+ v.get("message")
1100
+ .or(v.get("error"))
1101
+ .and_then(|m| m.as_str())
1102
+ .map(|s| s.to_string())
1103
+ })
1104
+ .unwrap_or_else(|| format!("HTTP {}", status_code));
1105
+ tracing::warn!(
1106
+ op = "core.workflowRuns.get.failed",
1107
+ status = status_code,
1108
+ "Workflow run fetch failed: {}",
1109
+ error_msg
1110
+ );
1111
+ Response::err(
1112
+ id.to_string(),
1113
+ "WORKFLOW_RUN_GET_FAILED",
1114
+ &format!("HTTP {}: {}", status_code, error_msg),
1115
+ )
1116
+ }
1117
+ }
1118
+ Err(e) => {
1119
+ if e.is_connect() {
1120
+ Response::err(
1121
+ id.to_string(),
1122
+ "ENGINE_UNAVAILABLE",
1123
+ &format!("Cannot connect to engine at {}. Is the engine running?", engine_url),
1124
+ )
1125
+ } else {
1126
+ Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string())
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ // =============================================================================
1133
+ // Debug (stateless)
1134
+ // =============================================================================
1135
+
1136
+ /// debug.isDevMode — check if running in development mode
1137
+ ///
1138
+ /// Reads EKKA_ENV environment variable directly (no host state needed).
1139
+ /// Returns { isDevMode: bool }.
1140
+ fn handle_is_dev_mode(id: &str) -> Response {
1141
+ tracing::info!(op = "core.debug.isDevMode", "Handling debug.isDevMode");
1142
+
1143
+ let is_dev = std::env::var("EKKA_ENV")
1144
+ .map(|v| v == "development")
1145
+ .unwrap_or(false);
1146
+
1147
+ Response::ok(
1148
+ id.to_string(),
1149
+ serde_json::json!({ "isDevMode": is_dev }),
1150
+ )
1151
+ }
1152
+
1153
+ // =============================================================================
1154
+ // Main Loop
1155
+ // =============================================================================
1156
+
1157
+ fn main() {
1158
+ // Initialize tracing to stderr (stdout is reserved for JSON-RPC)
1159
+ tracing_subscriber::fmt()
1160
+ .with_writer(io::stderr)
1161
+ .with_env_filter(
1162
+ tracing_subscriber::EnvFilter::from_default_env()
1163
+ .add_directive("ekka_desktop_core=info".parse().unwrap()),
1164
+ )
1165
+ .with_target(true)
1166
+ .init();
1167
+
1168
+ tracing::info!(op = "core.startup", "EKKA Desktop Core starting (stdio JSON-RPC)");
1169
+
1170
+ let stdin = io::stdin();
1171
+ let stdout = io::stdout();
1172
+ let mut stdout_lock = stdout.lock();
1173
+
1174
+ for line in stdin.lock().lines() {
1175
+ let line = match line {
1176
+ Ok(l) => l,
1177
+ Err(e) => {
1178
+ tracing::error!(op = "core.stdin.error", error = %e, "Failed to read stdin");
1179
+ break;
1180
+ }
1181
+ };
1182
+
1183
+ let trimmed = line.trim();
1184
+ if trimmed.is_empty() {
1185
+ continue;
1186
+ }
1187
+
1188
+ // Parse request
1189
+ let req: Request = match serde_json::from_str(trimmed) {
1190
+ Ok(r) => r,
1191
+ Err(e) => {
1192
+ // Can't correlate to an ID, write error with empty ID
1193
+ let resp = Response::err(
1194
+ String::new(),
1195
+ "PARSE_ERROR",
1196
+ &format!("Invalid JSON request: {}", e),
1197
+ );
1198
+ let _ = serde_json::to_writer(&mut stdout_lock, &resp);
1199
+ let _ = stdout_lock.write_all(b"\n");
1200
+ let _ = stdout_lock.flush();
1201
+ continue;
1202
+ }
1203
+ };
1204
+
1205
+ tracing::debug!(op = "core.dispatch", id = %req.id, op_name = %req.op, "Dispatching");
1206
+
1207
+ // Dispatch and respond
1208
+ let resp = dispatch(&req);
1209
+
1210
+ if let Err(e) = serde_json::to_writer(&mut stdout_lock, &resp) {
1211
+ tracing::error!(op = "core.stdout.error", error = %e, "Failed to write response");
1212
+ break;
1213
+ }
1214
+ if let Err(e) = stdout_lock.write_all(b"\n") {
1215
+ tracing::error!(op = "core.stdout.error", error = %e, "Failed to write newline");
1216
+ break;
1217
+ }
1218
+ if let Err(e) = stdout_lock.flush() {
1219
+ tracing::error!(op = "core.stdout.error", error = %e, "Failed to flush stdout");
1220
+ break;
1221
+ }
1222
+ }
1223
+
1224
+ tracing::info!(op = "core.shutdown", "EKKA Desktop Core shutting down");
1225
+ }