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.
- package/bin/cli.js +1 -5
- package/package.json +1 -1
- package/template/src/demo/DemoApp.tsx +0 -44
- package/template/src/demo/layout/Sidebar.tsx +2 -13
- package/template/src/demo/pages/LoginPage.tsx +1 -2
- package/template/src/demo/pages/SystemPage.tsx +1 -1
- package/template/src/ekka/backend/demo.ts +1 -1
- package/template/src/ekka/constants.ts +0 -4
- package/template/src/ekka/index.ts +0 -2
- package/template/src/ekka/internal/backend.ts +8 -8
- package/template/src/ekka/ops/index.ts +1 -2
- package/template/src/ekka/utils/index.ts +0 -1
- package/template/src-tauri/Cargo.toml +2 -0
- package/template/src-tauri/crates/ekka-desktop-core/Cargo.toml +30 -0
- package/template/src-tauri/crates/ekka-desktop-core/build.rs +42 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/bootstrap.rs +39 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/config.rs +32 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/device_secret.rs +74 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/main.rs +1225 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/node_credentials.rs +413 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/node_vault_crypto.rs +57 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/node_vault_store.rs +198 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/security_epoch.rs +80 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/commands.rs +137 -958
- package/template/src-tauri/src/config.rs +4 -44
- package/template/src-tauri/src/core_process.rs +335 -0
- package/template/src-tauri/src/engine_process.rs +103 -0
- package/template/src-tauri/src/handlers/home.rs +1 -32
- package/template/src-tauri/src/main.rs +240 -153
- package/template/src-tauri/src/node_auth.rs +2 -748
- package/template/src-tauri/src/node_credentials.rs +2 -201
- package/template/src-tauri/src/node_runner.rs +2 -55
- package/template/src-tauri/src/node_vault_crypto.rs +0 -33
- package/template/src-tauri/src/node_vault_store.rs +1 -150
- package/template/src-tauri/src/ops/mod.rs +0 -2
- package/template/src-tauri/src/state.rs +7 -63
- package/template/src-tauri/src/types.rs +1 -23
- package/template/src-tauri/src/updater.rs +215 -0
- package/template/src-tauri/tauri.conf.json +9 -0
- package/template/src/demo/pages/DocGenPage.tsx +0 -731
- package/template/src/ekka/config.ts +0 -48
- package/template/src/ekka/ops/debug.ts +0 -68
- package/template/src/ekka/ops/executionRuns.ts +0 -147
- package/template/src/ekka/ops/workflowRuns.ts +0 -119
- package/template/src/ekka/utils/idempotency.ts +0 -14
- package/template/src-tauri/src/ops/home.rs +0 -251
- package/template/src-tauri/src/ops/runtime.rs +0 -21
- 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
|
+
}
|