victauri-cli 0.8.7

CLI for Victauri — scaffold tests, check running apps, record sessions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
//! Stdio-to-HTTP bridge for MCP clients like Claude Code.
//!
//! Reads JSON-RPC messages from stdin, forwards them to Victauri's Streamable HTTP
//! endpoint, parses SSE responses, and writes them back to stdout. This bridges
//! the gap between MCP hosts that expect stdio transport and Victauri's HTTP server.
//!
//! Why this exists (and why agents should connect through it, not a fixed `url:`):
//!
//! * **Connects instantly, app or no app (the cold-start guarantee).** Victauri's MCP
//!   server is embedded *inside* the Tauri app, so it only exists while the app runs. A
//!   naive proxy that discovered the backend before answering `initialize` would hang the
//!   MCP handshake whenever the app was not yet running — and the host (Claude Code) aborts
//!   at a 30s connection timeout, so **every fresh terminal opened before the app started
//!   failed to connect**. This bridge answers `initialize` and `tools/list` **locally**, so
//!   the MCP server always appears connected with its full tool surface. Only actual tool
//!   *calls* need a live backend; when none is running they return a clear, actionable error
//!   instead of hanging. When the app comes up (even minutes later, even after a restart)
//!   the bridge discovers it and emits `notifications/tools/list_changed`, so tools go live
//!   automatically with **no `/mcp` reconnect**.
//! * **Always reaches the RIGHT app.** A static `.mcp.json` URL hardcodes a port; when
//!   several Victauri apps run (or one falls back off a busy 7373), that port can point at
//!   the WRONG process. The bridge resolves the live backend **by app identity** at connect
//!   time and re-resolves on failure — so the agent can never get stuck talking to the
//!   wrong app. Select with `--app <identifier>` (or `VICTAURI_APP`); with no selector it
//!   uses the single running app, or errors clearly if several are running.
//! * **Survives server restarts.** Every dev rebuild/relaunch invalidates the MCP session.
//!   The bridge re-establishes a fresh backend session (re-discovering the port) on a stale
//!   session (404/409/422) or connection drop, so the agent's tool calls keep working
//!   without a reconnect.

use std::collections::HashSet;
use std::io::{BufRead, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use anyhow::{Result, bail};
use serde_json::{Value, json};

const MAX_RETRIES: usize = 4;
const RETRY_DELAY_MS: u64 = 400;
/// How often the background availability poller re-checks whether a backend became reachable
/// WHILE DOWN. On a down→up transition it emits `tools/list_changed` so the client swaps the
/// baked fallback tool list for the live one with no reconnect.
const POLL_INTERVAL_MS: u64 = 1500;
/// Slower poll cadence once the backend is already UP — we only need to catch a later restart,
/// so there is no reason to spawn a `tasklist`/`ps` + `/health` probe every 1.5s for the whole
/// session (over an 8h editor session that would be ~19k needless subprocess spawns).
const POLL_INTERVAL_UP_MS: u64 = 5000;
/// MCP protocol version advertised in the local `initialize` reply when the client did not
/// request one. When the client DOES request a version we echo it (guaranteed-accepted).
const DEFAULT_PROTOCOL_VERSION: &str = "2025-06-18";
/// Baked-in tool list (name + description per tool), shown while no app is running so the
/// MCP server always presents its full surface. Superseded by the live `tools/list` the
/// moment an app connects. Generated from the plugin's `#[tool]` annotations.
static TOOLS_FALLBACK_JSON: &str = include_str!("tools_fallback.json");
/// Server `instructions` returned by the local `initialize`, so an agent understands the
/// down-vs-up states before it makes a call.
const LOCAL_INIT_INSTRUCTIONS: &str = "Victauri MCP bridge. Tools act on a running Tauri app \
    (debug build). While no app is running the tool list is a static fallback and tool calls \
    report the backend as unreachable; start the app and the bridge connects automatically — \
    the tool list refreshes to the live set with no reconnect.";

/// A discovered, live Victauri backend.
#[derive(Clone, Debug)]
struct ServerInfo {
    port: u16,
    token: Option<String>,
    identifier: Option<String>,
    product_name: Option<String>,
}

impl ServerInfo {
    fn label(&self) -> String {
        let name = self
            .identifier
            .as_deref()
            .or(self.product_name.as_deref())
            .unwrap_or("<unknown app>");
        format!("{name} (port {})", self.port)
    }
}

/// Run the stdio bridge for MCP clients.
///
/// Unlike a naive proxy, this NEVER blocks the MCP handshake on discovering a backend:
/// `initialize`/`tools/list` are answered locally so the server always appears connected,
/// and only tool *calls* require a live app. `app` selects which app to bind when several
/// are running (matches the Tauri bundle identifier or product name; falls back to the
/// `VICTAURI_APP` env var).
///
/// # Errors
///
/// Returns an error only if the HTTP client or stdio pipes cannot be set up — never merely
/// because no app is running.
pub async fn run(wait: bool, app: Option<String>) -> Result<()> {
    // `--wait` is retained for backward compatibility with existing `.mcp.json` files but is
    // now a no-op: the handshake never blocks on discovery, so there is nothing to wait for.
    // Tool calls discover lazily and fail fast with an actionable message when the app is down.
    let _ = wait;
    let app = app.or_else(|| std::env::var("VICTAURI_APP").ok());
    let http = build_client()?;

    // The live backend, discovered lazily. `None` until an app is found — the bridge starts
    // and serves the handshake with no backend at all.
    let connection: Arc<Mutex<Option<ServerInfo>>> = Arc::new(Mutex::new(None));
    let session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
    // The client's `initialize` message, cached so we can hand-shake the BACKEND (replay it)
    // when we first forward a real request and after a restart. The client is answered
    // locally, so it only ever sends `initialize` once.
    let cached_init: Arc<Mutex<Option<Value>>> = Arc::new(Mutex::new(None));
    // Set once a backend handshake returns no `Mcp-Session-Id`: the server is stateless, so
    // there is no session to mint or lose and we must not re-`initialize` before every call.
    let stateless: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
    // Last-known backend availability — SOLELY owned by the poller (see H1 in the audit): the
    // request path never writes it, so the down→up edge is detected in exactly one place.
    let backend_up = Arc::new(AtomicBool::new(false));
    // Set once the client sends `notifications/initialized` (its OWN handshake completion), so
    // the poller never emits a server notification before the client has finished initializing —
    // not merely before we answered `initialize` (audit #3). Gating on the client's ack, not on
    // our local reply, closes the window where `list_changed` could precede the init response.
    let client_ready = Arc::new(AtomicBool::new(false));
    // stdout is shared with the background poller (which writes `list_changed`); every write
    // locks, emits one line, and flushes, so responses and notifications never interleave.
    let stdout = Arc::new(Mutex::new(std::io::stdout()));

    spawn_availability_poller(
        app.clone(),
        Arc::clone(&connection),
        Arc::clone(&session_id),
        Arc::clone(&stateless),
        Arc::clone(&backend_up),
        Arc::clone(&client_ready),
        Arc::clone(&stdout),
    );

    // Read stdin on a DEDICATED OS thread feeding an async channel, so the blocking read never
    // parks a tokio worker (which, on a single-vCPU host, would starve the timer and stop the
    // availability poller from ever firing — H2 in the audit). The async loop stays responsive.
    let (line_tx, mut line_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
    std::thread::spawn(move || {
        let stdin = std::io::stdin();
        for line in stdin.lock().lines() {
            let Ok(line) = line else { break };
            if line_tx.send(line).is_err() {
                break; // async side gone
            }
        }
        // EOF (or send failure) drops line_tx → the async loop's recv() returns None → shutdown.
    });

    while let Some(line) = line_rx.recv().await {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        let msg: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("victauri-bridge: invalid JSON on stdin: {e}");
                continue;
            }
        };

        let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
        let id = msg.get("id").cloned().unwrap_or(Value::Null);
        let is_notification = msg.get("id").is_none();

        match method {
            // ── Answered locally — never block on a backend ──────────────────────
            "initialize" => {
                // Cache for the lazy backend handshake, then reply immediately.
                *locked(&cached_init) = Some(msg.clone());
                write_value(&stdout, &local_initialize_response(&msg));
            }
            // The client's ack of OUR local initialize. It must never reach a backend (we
            // handshake the backend separately) and needs no response — but it is the point at
            // which the client is ready to receive server notifications, so mark it here (NOT on
            // `initialize`) so the poller can't emit `list_changed` before the client is ready.
            "notifications/initialized" => {
                client_ready.store(true, Ordering::Release);
                // If the backend already came up during the init-handshake window, the poller
                // saw that down→up edge but skipped the emit (the client wasn't ready yet), and
                // there is no fresh edge to emit on later. Emit once now so the client still
                // refreshes to the live tool list — idempotent and cheap. (A compliant client's
                // first `tools/list` after this ack already returns the live list directly; this
                // just covers a client that relies solely on the notification.)
                if backend_up.load(Ordering::Acquire) {
                    write_notification(&stdout, "notifications/tools/list_changed");
                    write_notification(&stdout, "notifications/resources/list_changed");
                }
            }
            "ping" => {
                write_value(&stdout, &json!({"jsonrpc": "2.0", "id": id, "result": {}}));
            }
            // ── List methods: live when up, graceful placeholder when down ───────
            "tools/list" => {
                match forward_when_up(
                    &http,
                    &connection,
                    &session_id,
                    &stateless,
                    &cached_init,
                    app.as_deref(),
                    &msg,
                )
                .await
                {
                    Some(payloads) => write_payloads(&stdout, &payloads),
                    // App down (or a transient fetch miss): serve the full baked fallback so
                    // the agent still sees every tool. The live list arrives via
                    // `tools/list_changed` when the app comes up.
                    None => write_value(&stdout, &fallback_tools_response(&id)),
                }
            }
            "resources/list" | "resources/templates/list" | "prompts/list" => {
                match forward_when_up(
                    &http,
                    &connection,
                    &session_id,
                    &stateless,
                    &cached_init,
                    app.as_deref(),
                    &msg,
                )
                .await
                {
                    Some(payloads) => write_payloads(&stdout, &payloads),
                    // Empty (but valid) result when down — avoids a startup error for a
                    // capability we advertise; the real list arrives via `list_changed`.
                    None => write_value(&stdout, &empty_list_response(method, &id)),
                }
            }
            // ── Everything else (tools/call, resources/read, …) needs a live app ─
            _ => {
                match forward_with_retries(
                    &http,
                    &connection,
                    &session_id,
                    &stateless,
                    &cached_init,
                    app.as_deref(),
                    &msg,
                )
                .await
                {
                    ForwardResult::Payloads(payloads) => write_payloads(&stdout, &payloads),
                    ForwardResult::Accepted => {}
                    ForwardResult::Unreachable(err_msg) => {
                        // A notification to a down backend is simply dropped (no id to answer).
                        if !is_notification {
                            write_value(
                                &stdout,
                                &json!({
                                    "jsonrpc": "2.0",
                                    "id": id,
                                    "error": { "code": -32000, "message": err_msg }
                                }),
                            );
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

/// The `initialize` reply the bridge synthesizes itself, so the MCP server is "connected"
/// the instant Claude Code launches — with no app running. Advertises `tools`/`resources`
/// with `listChanged` so the client refreshes its lists when the app later comes up.
fn local_initialize_response(client_msg: &Value) -> Value {
    let id = client_msg.get("id").cloned().unwrap_or(Value::Null);
    // Echo the client's requested protocol version (guaranteed-acceptable to it); fall back
    // to a known version only if it sent none.
    let protocol_version = client_msg
        .get("params")
        .and_then(|p| p.get("protocolVersion"))
        .and_then(|v| v.as_str())
        .unwrap_or(DEFAULT_PROTOCOL_VERSION)
        .to_string();
    json!({
        "jsonrpc": "2.0",
        "id": id,
        "result": {
            "protocolVersion": protocol_version,
            // Mirror the plugin's real capabilities: it advertises `resources` (read) with
            // list-change notifications but DELIBERATELY not `subscribe` — server-initiated
            // resource-update push is not implemented, and this proxy has no channel to deliver
            // it. Advertising `subscribe` here would mislead a client into waiting forever.
            "capabilities": {
                "tools": { "listChanged": true },
                "resources": { "listChanged": true }
            },
            "serverInfo": { "name": "victauri-bridge", "version": env!("CARGO_PKG_VERSION") },
            "instructions": LOCAL_INIT_INSTRUCTIONS
        }
    })
}

/// The full baked tool list as MCP `Tool` objects. Each carries the real name + description
/// and a permissive object input schema — enough for an agent to see what exists while the
/// app is down; the live `tools/list` (with exact per-tool schemas) supersedes it on connect.
fn fallback_tools() -> Vec<Value> {
    let parsed: Vec<Value> = serde_json::from_str(TOOLS_FALLBACK_JSON).unwrap_or_default();
    parsed
        .into_iter()
        .filter_map(|t| {
            let name = t.get("name")?.as_str()?.to_string();
            let description = t
                .get("description")
                .and_then(|d| d.as_str())
                .unwrap_or_default()
                .to_string();
            Some(json!({
                "name": name,
                "description": description,
                "inputSchema": { "type": "object" }
            }))
        })
        .collect()
}

fn fallback_tools_response(id: &Value) -> Value {
    json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": fallback_tools() } })
}

/// A valid but empty list result for a `*/list` method served while the app is down.
fn empty_list_response(method: &str, id: &Value) -> Value {
    let key = match method {
        "resources/list" => "resources",
        "resources/templates/list" => "resourceTemplates",
        "prompts/list" => "prompts",
        _ => "items",
    };
    json!({ "jsonrpc": "2.0", "id": id, "result": { key: [] } })
}

/// Lock a mutex, recovering the guard even if a previous holder panicked (poison), rather than
/// cascading a single panic into the death of the whole bridge (or, silently, the reconnect
/// poller). Mirrors the poison-tolerant pattern used across `victauri-core`.
fn locked<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Write one JSON-RPC value as a line to the shared stdout.
fn write_value(stdout: &Arc<Mutex<std::io::Stdout>>, v: &Value) {
    let mut o = locked(stdout);
    let _ = writeln!(o, "{v}");
    let _ = o.flush();
}

/// Relay already-serialized JSON-RPC payload lines (from a backend response) verbatim.
fn write_payloads(stdout: &Arc<Mutex<std::io::Stdout>>, payloads: &[String]) {
    let mut o = locked(stdout);
    for payload in payloads {
        let _ = writeln!(o, "{payload}");
    }
    let _ = o.flush();
}

/// Emit a server→client JSON-RPC notification (no id).
fn write_notification(stdout: &Arc<Mutex<std::io::Stdout>>, method: &str) {
    write_value(stdout, &json!({ "jsonrpc": "2.0", "method": method }));
}

/// Build one JSON-RPC error-response line for `msg`'s id — used when a forwarded REQUEST gets
/// no relayable payload (an empty/non-JSON 2xx body, or a 202), so the client's id never hangs.
fn error_for_request(msg: &Value, code: i64, message: &str) -> String {
    json!({
        "jsonrpc": "2.0",
        "id": msg.get("id"),
        "error": { "code": code, "message": message }
    })
    .to_string()
}

/// Actionable message returned when a tool call can't reach a backend.
fn unreachable_message() -> String {
    "Victauri backend not reachable: no running Tauri app with the Victauri plugin (debug \
     build) was found. Start the app (e.g. `npm run tauri dev` / `pnpm tauri dev`); the bridge \
     connects automatically when it comes up — no reconnect needed. If several Victauri apps \
     run, select one with `--app <bundle-identifier>` or the VICTAURI_APP env var."
        .to_string()
}

/// Background task that watches for the backend becoming reachable and, on a down→up
/// transition, tells the client to refresh its tool/resource lists — so the baked fallback
/// is replaced by the live, version-accurate set with no `/mcp` reconnect.
#[allow(clippy::too_many_arguments)]
fn spawn_availability_poller(
    app: Option<String>,
    connection: Arc<Mutex<Option<ServerInfo>>>,
    session_id: Arc<Mutex<Option<String>>>,
    stateless: Arc<Mutex<bool>>,
    backend_up: Arc<AtomicBool>,
    client_ready: Arc<AtomicBool>,
    stdout: Arc<Mutex<std::io::Stdout>>,
) {
    tokio::spawn(async move {
        loop {
            // Poll fast while DOWN (a freshly-started app is noticed within ~1.5s) and back off
            // while UP (we only need to catch a later restart).
            let interval = if backend_up.load(Ordering::Acquire) {
                POLL_INTERVAL_UP_MS
            } else {
                POLL_INTERVAL_MS
            };
            tokio::time::sleep(Duration::from_millis(interval)).await;
            let found = discover_one(app.as_deref()).await;
            let up = found.is_some();
            if let Some(info) = found {
                *locked(&connection) = Some(info);
            } else {
                // Backend gone — drop the cached connection/session so nothing can reuse a stale
                // (port, token) pair (defense-in-depth for audit #1; the request path also
                // re-resolves the trusted entry on every call).
                *locked(&connection) = None;
                *locked(&session_id) = None;
                *locked(&stateless) = false;
            }
            // The poller is the SOLE owner of `backend_up`: the request path no longer writes it,
            // so the down→up edge is detected here exactly once and can never be silently consumed
            // by a tool call that happened to reconnect first.
            let was = backend_up.swap(up, Ordering::AcqRel);
            // Only announce a refresh once the CLIENT has finished initializing (sent
            // `notifications/initialized`) — a server notification before that is a lifecycle
            // violation a strict client may reject.
            if up && !was && client_ready.load(Ordering::Acquire) {
                write_notification(&stdout, "notifications/tools/list_changed");
                write_notification(&stdout, "notifications/resources/list_changed");
            }
        }
    });
}

/// Outcome of forwarding one JSON-RPC message to a live backend.
enum ForwardResult {
    /// Backend responded — relay these serialized payload lines to the client.
    Payloads(Vec<String>),
    /// A notification the backend accepted (202) — nothing to relay.
    Accepted,
    /// No live backend could be reached — the message carries an actionable explanation.
    Unreachable(String),
}

/// Forward a list-style request, returning its payloads only if a backend is currently
/// reachable; `None` when the app is down (the caller then serves a local placeholder). Delegates
/// to `forward_with_retries`, which re-resolves the trusted backend itself — so a live `tools/list`
/// pays exactly one discovery pass and a down one fails fast to the fallback.
#[allow(clippy::too_many_arguments)]
async fn forward_when_up(
    http: &reqwest::Client,
    connection: &Arc<Mutex<Option<ServerInfo>>>,
    session_id: &Arc<Mutex<Option<String>>>,
    stateless: &Arc<Mutex<bool>>,
    cached_init: &Arc<Mutex<Option<Value>>>,
    app: Option<&str>,
    msg: &Value,
) -> Option<Vec<String>> {
    match forward_with_retries(
        http,
        connection,
        session_id,
        stateless,
        cached_init,
        app,
        msg,
    )
    .await
    {
        ForwardResult::Payloads(payloads) => Some(payloads),
        _ => None,
    }
}

/// Forward a message to the live backend, establishing/recovering the backend session and
/// retrying across a restart. Discovers a backend first if we don't have one; returns
/// `Unreachable` (never blocks indefinitely) when no app is running.
async fn forward_with_retries(
    http: &reqwest::Client,
    connection: &Arc<Mutex<Option<ServerInfo>>>,
    session_id: &Arc<Mutex<Option<String>>>,
    stateless: &Arc<Mutex<bool>>,
    cached_init: &Arc<Mutex<Option<Value>>>,
    app: Option<&str>,
    msg: &Value,
) -> ForwardResult {
    let is_notification = msg.get("id").is_none();

    // SECURITY (audit #1): re-resolve the trusted backend on EVERY forward — never reuse a cached
    // `(port, token)` without re-confirming, right now, that the port still belongs to a live,
    // trusted, identity-matched app. `scan_once` re-applies `dir_is_trusted` + liveness + `--app`
    // identity and yields the port and token together. Without this, after the app shut down (its
    // discovery entry gone) an attacker who bound the freed port would receive the cached Bearer
    // token and could relay forged tool results. This is ONE discovery pass (no 1s-sleep retries):
    // a live app resolves fast; a down app fails fast to the actionable message / fallback.
    match scan_once(app).await {
        Selection::One(info) => {
            *locked(connection) = Some(info);
        }
        Selection::Ambiguous(labels) => {
            *locked(connection) = None;
            return ForwardResult::Unreachable(format!(
                "Multiple Victauri apps are running:\n  {}\nSelect one with \
                 `--app <bundle-identifier>` or the VICTAURI_APP env var.",
                labels.join("\n  ")
            ));
        }
        Selection::None => {
            *locked(connection) = None;
            // Fail-fast by design: a tool call issued in the sub-second window where a restarting
            // app has removed its old discovery entry but not yet written the new one gets the
            // actionable "unreachable" message rather than waiting. This is the price of the audit
            // #1 rule (never reuse a cached connection); the retry loop's restart patience still
            // applies once a live trusted backend is found, and the next call ~1s later succeeds.
            return ForwardResult::Unreachable(unreachable_message());
        }
    }

    let mut last_err = None;

    for attempt in 0..MAX_RETRIES {
        // Re-establish a fresh backend session BEFORE replaying the real request when we have
        // none (first forward, or after a restart invalidated it). This is what makes
        // restart-recovery work — replaying a tool call with no session would 422.
        if !*locked(stateless) {
            let need_reinit = locked(session_id).is_none();
            if need_reinit {
                let init = locked(cached_init).clone();
                if let Some(init) = init
                    && let Some((port, token)) = conn_parts(connection)
                {
                    // We don't relay the backend handshake response to the client; it already
                    // believes it is initialized (we answered locally).
                    if let Ok(out) = post_message(http, port, token.as_deref(), None, &init).await {
                        let backend_sid = out.session_id.clone();
                        if let Some(sid) = out.session_id {
                            *locked(session_id) = Some(sid);
                        } else if !out.stale_session {
                            // Handshake succeeded with no session id → stateless backend.
                            *locked(stateless) = true;
                        }
                        // Complete the MCP lifecycle for a STATEFUL backend (harmless for the
                        // stateless default): the client's `notifications/initialized` was
                        // answered locally, so replay it to the backend now — a stateful rmcp
                        // session may gate tool calls on having received it.
                        let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
                        let _ = post_message(
                            http,
                            port,
                            token.as_deref(),
                            backend_sid.as_deref(),
                            &note,
                        )
                        .await;
                    }
                }
            }
        }

        let Some((port, token)) = conn_parts(connection) else {
            return ForwardResult::Unreachable(unreachable_message());
        };
        let sid = locked(session_id).clone();

        match post_message(http, port, token.as_deref(), sid.as_deref(), msg).await {
            Ok(out) => {
                if let Some(new_sid) = out.session_id {
                    *locked(session_id) = Some(new_sid);
                }

                if out.stale_session {
                    eprintln!(
                        "victauri-bridge: stale session (HTTP {}), re-establishing (attempt {}/{})",
                        out.status,
                        attempt + 1,
                        MAX_RETRIES
                    );
                    *locked(session_id) = None;
                    if attempt + 1 < MAX_RETRIES {
                        tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
                        if let Ok(new_conn) = discover_and_select(false, app).await {
                            *locked(connection) = Some(new_conn);
                        }
                    }
                    last_err = Some(format!("Victauri returned {}", out.status));
                    continue;
                }

                if out.accepted {
                    if is_notification {
                        return ForwardResult::Accepted;
                    }
                    // A 202 to a REQUEST leaves no body to relay — Victauri never does this, but a
                    // proxy must not leave the client's id hanging if a backend ever does.
                    return ForwardResult::Payloads(vec![error_for_request(
                        msg,
                        -32603,
                        "backend accepted the request with no response (HTTP 202)",
                    )]);
                }

                // A successful response that yields NO relayable payload for a REQUEST (an empty
                // or non-JSON 2xx body — see `post_message`) must likewise not hang the client's
                // id: synthesize an error rather than returning empty payloads (audit #2).
                if !is_notification && out.payloads.is_empty() {
                    return ForwardResult::Payloads(vec![error_for_request(
                        msg,
                        -32603,
                        "backend returned an empty or non-JSON response",
                    )]);
                }

                return ForwardResult::Payloads(out.payloads);
            }
            Err(e) => {
                eprintln!(
                    "victauri-bridge: connection failed (attempt {}/{}): {e}",
                    attempt + 1,
                    MAX_RETRIES
                );
                *locked(session_id) = None;
                if attempt + 1 < MAX_RETRIES {
                    tokio::time::sleep(Duration::from_millis(
                        RETRY_DELAY_MS * (attempt as u64 + 1),
                    ))
                    .await;
                    // Re-discover; the app may have restarted on a new port, or gone away.
                    match discover_and_select(false, app).await {
                        Ok(new_conn) => {
                            eprintln!("victauri-bridge: reconnected to {}", new_conn.label());
                            *locked(connection) = Some(new_conn);
                        }
                        Err(_) => {
                            *locked(connection) = None;
                        }
                    }
                }
                last_err = Some(format!("Victauri server unreachable ({e})"));
                continue;
            }
        }
    }

    // Retries exhausted — the app is down or perpetually restarting. `last_err` is logged to
    // stderr already; the client gets the actionable remedy.
    let _ = last_err;
    ForwardResult::Unreachable(unreachable_message())
}

fn build_client() -> Result<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(120))
        .connect_timeout(std::time::Duration::from_secs(10))
        .build()
        .map_err(Into::into)
}

fn conn_parts(connection: &Arc<Mutex<Option<ServerInfo>>>) -> Option<(u16, Option<String>)> {
    locked(connection)
        .as_ref()
        .map(|s| (s.port, s.token.clone()))
}

/// Outcome of forwarding one JSON-RPC message to the backend.
struct PostOutcome {
    status: u16,
    session_id: Option<String>,
    stale_session: bool,
    accepted: bool,
    payloads: Vec<String>,
}

/// Forward a single JSON-RPC message to `127.0.0.1:<port>/mcp` and parse the response.
async fn post_message(
    http: &reqwest::Client,
    port: u16,
    token: Option<&str>,
    session_id: Option<&str>,
    msg: &serde_json::Value,
) -> Result<PostOutcome> {
    let url = format!("http://127.0.0.1:{port}/mcp");
    let mut req = http
        .post(&url)
        .header("Content-Type", "application/json")
        .header("Accept", "application/json, text/event-stream");
    if let Some(t) = token {
        req = req.header("Authorization", format!("Bearer {t}"));
    }
    if let Some(sid) = session_id {
        req = req.header("Mcp-Session-Id", sid);
    }

    let resp = req.json(msg).send().await?;
    let status = resp.status().as_u16();
    let new_sid = resp
        .headers()
        .get("mcp-session-id")
        .and_then(|v| v.to_str().ok())
        .map(String::from);

    // 404/409 = unknown/terminated session; 422 = "expect initialize" (no/!init session).
    // All three mean "the session is gone — re-establish it".
    let stale_session = matches!(status, 404 | 409 | 422);
    let accepted = status == 202;

    let mut payloads = Vec::new();
    if !stale_session && status != 202 {
        let content_type = resp
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_string();
        let body = resp.text().await.unwrap_or_default();

        if !(200..300).contains(&status) {
            // Surface a JSON-RPC error for the original request id.
            payloads.push(
                serde_json::json!({
                    "jsonrpc": "2.0",
                    "id": msg.get("id"),
                    "error": { "code": -32000, "message": format!("Victauri returned {status}: {body}") }
                })
                .to_string(),
            );
        } else if content_type.contains("text/event-stream") {
            for sse_line in body.lines() {
                if let Some(data) = sse_line.strip_prefix("data: ") {
                    let data = data.trim();
                    if !data.is_empty() && serde_json::from_str::<serde_json::Value>(data).is_ok() {
                        payloads.push(data.to_string());
                    }
                }
            }
        } else {
            let body = body.trim();
            // Relay a non-SSE body ONLY if it is valid JSON, and relay its COMPACT single-line
            // form. Never write non-JSON onto the client's JSON-RPC stream, and never split one
            // message across stdout lines: a valid but pretty-printed body would otherwise be
            // broken into several frames by `writeln!`. Re-serializing the parsed value guarantees
            // exactly one line, matching the SSE path's per-line rigor. An empty or non-JSON body
            // yields no payload; the caller then synthesizes an error for a request (no hang).
            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(body) {
                payloads.push(parsed.to_string());
            }
        }
    }

    Ok(PostOutcome {
        status,
        session_id: new_sid,
        stale_session,
        accepted,
        payloads,
    })
}

/// One discovery pass: honor a `VICTAURI_PORT` override, else scan the discovery dir for
/// live, health-checked backends and select the one matching `app` (or the sole one). Does
/// not sleep or retry — callers add patience if they want it.
async fn scan_once(app: Option<&str>) -> Selection {
    // Explicit env override wins (a developer pinning a specific port).
    if let Ok(p) = std::env::var("VICTAURI_PORT")
        && let Ok(port) = p.parse::<u16>()
        && health_ok(port).await
    {
        return Selection::One(ServerInfo {
            port,
            // An EMPTY/whitespace `VICTAURI_AUTH_TOKEN` is "not configured", NOT "send an
            // empty Bearer" — it must fall through to the discovered token for this exact
            // port (see `normalize_env_token`).
            token: normalize_env_token(std::env::var("VICTAURI_AUTH_TOKEN").ok())
                .or_else(|| discover_token_for_port(port)),
            identifier: None,
            product_name: None,
        });
    }

    // Liveness-FIRST (batched), then health. `alive_pids` snapshots every live PID in ONE OS
    // call (~60ms), so a machine with many stale discovery dirs (dead dev sessions) is filtered
    // for ~free instead of one process spawn PER dir. Crucially we then health-check ONLY the
    // live-pid entries: a stale dir whose long-dead port was reused by some other service is
    // never probed (that mis-order made a down-state scan hang on an unresponsive reused port).
    // When the app is down there are zero live entries, so zero health probes — the poller
    // stays cheap. `None` (enumeration unavailable) falls back to the per-pid check.
    //
    // HONEST LIMITATION (documented residual, not closed here): liveness is PID-based, and PIDs
    // are reused. `/health` returns a static `ok` and carries no token, so it proves *something*
    // is bound but does not authenticate the listener as the real app. A stale `<pid>` dir whose
    // recorded PID has been recycled onto another of our live processes, combined with an attacker
    // binding the freed port, could still pass liveness+health+identity and receive the token.
    // Re-resolving on every forward (audit #1) shrinks this to a per-call coincidence rather than
    // a cache-lifetime one; fully closing it needs mutual auth on `/health` (a plugin-side change).
    let alive = alive_pids();
    let is_alive = |pid: u32| {
        alive
            .as_ref()
            .map_or_else(|| is_process_alive(pid), |set| set.contains(&pid))
    };
    let mut live = Vec::new();
    for (pid, s) in discover_entries() {
        if is_alive(pid) && health_ok(s.port).await {
            live.push(s);
        }
    }
    select(&live, app)
}

/// A single non-blocking discovery attempt — `Some` iff exactly one matching live backend is
/// found. Used by the availability poller and the `*/list` fast path.
async fn discover_one(app: Option<&str>) -> Option<ServerInfo> {
    match scan_once(app).await {
        Selection::One(s) => Some(s),
        _ => None,
    }
}

/// Discover live Victauri backends and select the one matching `app` (or the only one),
/// retrying for a short window (or ~30s under `wait`).
async fn discover_and_select(wait: bool, app: Option<&str>) -> Result<ServerInfo> {
    let max_attempts = if wait { 30 } else { 3 };
    let delay = Duration::from_secs(1);

    for attempt in 0..max_attempts {
        match scan_once(app).await {
            Selection::One(s) => {
                eprintln!("victauri-bridge: connected to {}", s.label());
                return Ok(s);
            }
            Selection::None if attempt + 1 < max_attempts => {
                if attempt == 0 {
                    eprintln!("victauri-bridge: waiting for Victauri server...");
                }
                tokio::time::sleep(delay).await;
            }
            Selection::None => {
                bail!(
                    "Could not connect to Victauri server.\n\
                     Is your Tauri app running (debug build)? Start it with: pnpm run tauri dev"
                );
            }
            Selection::Ambiguous(labels) => {
                bail!(
                    "Multiple Victauri apps are running:\n  {}\n\
                     Specify which one with `victauri bridge --app <identifier>` (or set \
                     VICTAURI_APP). The identifier is your Tauri bundle identifier.",
                    labels.join("\n  ")
                );
            }
        }
    }

    bail!("Could not connect to a matching Victauri server")
}

enum Selection {
    One(ServerInfo),
    None,
    Ambiguous(Vec<String>),
}

/// Pick the server matching `app`, or the sole running server.
fn select(live: &[ServerInfo], app: Option<&str>) -> Selection {
    if live.is_empty() {
        return Selection::None;
    }
    if let Some(app) = app {
        let needle = app.to_ascii_lowercase();
        // Prefer an exact identifier/product_name match, then a substring match.
        let exact = live.iter().find(|s| {
            s.identifier
                .as_deref()
                .map(str::to_ascii_lowercase)
                .as_deref()
                == Some(&needle)
                || s.product_name
                    .as_deref()
                    .map(str::to_ascii_lowercase)
                    .as_deref()
                    == Some(&needle)
        });
        if let Some(s) = exact {
            return Selection::One(s.clone());
        }
        let partial = live.iter().find(|s| {
            s.identifier
                .as_deref()
                .is_some_and(|i| i.to_ascii_lowercase().contains(&needle))
                || s.product_name
                    .as_deref()
                    .is_some_and(|p| p.to_ascii_lowercase().contains(&needle))
        });
        return match partial {
            Some(s) => Selection::One(s.clone()),
            None => Selection::None,
        };
    }
    // No app specified: fine if exactly one is running; ambiguous otherwise.
    if live.len() == 1 {
        Selection::One(live[0].clone())
    } else {
        Selection::Ambiguous(live.iter().map(ServerInfo::label).collect())
    }
}

/// Read `<temp>/victauri/<pid>/` discovery entries (port + token + identity) as
/// `(pid, ServerInfo)`. Pure filesystem work — NO process-liveness or HTTP health check here,
/// so it stays cheap even with many stale directories. Callers apply the health-then-liveness
/// filter in [`scan_once`], which is what keeps a down-state scan fast: a dead/stale port
/// refuses the connection instantly, so no process enumeration runs at all.
fn discover_entries() -> Vec<(u32, ServerInfo)> {
    let root = std::env::temp_dir().join("victauri");
    let mut out = Vec::new();
    // The root itself is security-sensitive: its owner can rename a trusted PID
    // directory after our child check and swap in attacker-controlled files.
    if !dir_is_trusted(&root) {
        return out;
    }
    let Ok(entries) = std::fs::read_dir(&root) else {
        return out;
    };
    for entry in entries.filter_map(Result::ok) {
        let pid_str = entry.file_name().to_string_lossy().to_string();
        let Ok(pid) = pid_str.parse::<u32>() else {
            continue;
        };
        let dir = entry.path();
        // Shared-temp hardening (audit #15, read side). The discovery root lives under a
        // world-writable temp dir on Unix, so a local attacker can plant a fake `<pid>`
        // directory — named after one of THEIR own live processes, so the liveness check
        // passes — pointing at a server they control, and harvest the real Bearer token we
        // send it (and feed us forged tool results). Trust a directory only if it is a real
        // directory we own and is not group/other-writable — the same guard
        // `victauri-test::discovery` already applies. The bridge is the path Claude Code
        // connects through, so this is the highest-value read-side sink.
        if !dir_is_trusted(&dir) {
            continue;
        }
        let Ok(port_s) = std::fs::read_to_string(dir.join("port")) else {
            continue;
        };
        let Ok(port) = port_s.trim().parse::<u16>() else {
            continue;
        };
        let token = std::fs::read_to_string(dir.join("token"))
            .ok()
            .map(|t| t.trim().to_string())
            .filter(|t| !t.is_empty());
        let (identifier, product_name) = std::fs::read_to_string(dir.join("metadata.json"))
            .ok()
            .and_then(|m| serde_json::from_str::<serde_json::Value>(&m).ok())
            .map_or((None, None), |m| {
                (
                    m.get("identifier")
                        .and_then(|v| v.as_str())
                        .map(String::from),
                    m.get("product_name")
                        .and_then(|v| v.as_str())
                        .map(String::from),
                )
            });
        out.push((
            pid,
            ServerInfo {
                port,
                token,
                identifier,
                product_name,
            },
        ));
    }
    out
}

/// The discovered backends as `ServerInfo` (dropping the pid) — for callers that only need
/// the server list, e.g. token lookup for a `VICTAURI_PORT` override.
fn discover_servers() -> Vec<ServerInfo> {
    discover_entries().into_iter().map(|(_, s)| s).collect()
}

/// Normalize a configured `VICTAURI_AUTH_TOKEN` value: an empty or whitespace-only
/// token is "not configured" (`None`), never an empty Bearer header.
///
/// The MCP server's contract is "auth is on by default unless `auth_disabled()`": the
/// plugin builder's `resolve_auth_token` generates a real token rather than disabling
/// auth when the configured token is blank. This is the client-side mirror — a botched
/// `VICTAURI_AUTH_TOKEN=""` must NOT make the bridge send an empty token to an
/// auth-enabled server (which would 401 every call); it must fall through to the token
/// discovered for the target port. Matches the filter used by every other token read
/// site (`discover_servers`, `victauri-test` discovery/app/client).
fn normalize_env_token(raw: Option<String>) -> Option<String> {
    raw.map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
}

/// Token belonging to the exact server selected by a `VICTAURI_PORT` override.
///
/// Never send a token discovered for one app to an unrelated localhost port.
fn discover_token_for_port(port: u16) -> Option<String> {
    token_for_port(&discover_servers(), port)
}

fn token_for_port(servers: &[ServerInfo], port: u16) -> Option<String> {
    servers
        .iter()
        .find(|server| server.port == port)
        .and_then(|server| server.token.clone())
}

/// Shared, warm HTTP client for `/health` probes. Built ONCE (rebuilding per call incurred
/// cold-start latency that made the FIRST probe against a live server intermittently exceed a
/// tight timeout — so discovery missed a running app on first contact). The CONNECT is bounded
/// tightly so a closed/filtered local port fast-fails (Windows does not always send a prompt
/// RST for a closed loopback port), while the total timeout stays generous: a live /health
/// answers in <50ms, so the larger budget only ever applies to the rare connected-but-
/// unresponsive reused port.
fn health_client() -> &'static reqwest::Client {
    static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .connect_timeout(Duration::from_millis(1200))
            .timeout(Duration::from_secs(3))
            .build()
            .unwrap_or_else(|_| reqwest::Client::new())
    })
}

async fn health_ok(port: u16) -> bool {
    let url = format!("http://127.0.0.1:{port}/health");
    health_client()
        .get(&url)
        .send()
        .await
        .is_ok_and(|r| r.status().is_success())
}

/// Resolve a System32 executable from `%SystemRoot%` instead of `PATH`, so an attacker-writable
/// `PATH` entry cannot shadow it. The availability poller spawns `tasklist` repeatedly, so this
/// PATH-hijack surface is continuous — always invoke it by absolute path.
#[cfg(windows)]
fn system32_exe(name: &str) -> String {
    let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string());
    format!("{root}\\System32\\{name}")
}

/// Resolve a core utility to an absolute path (defeats `PATH`-hijack of the poller's repeated
/// `ps`/`kill` spawns), falling back to the bare name only if neither canonical location exists.
#[cfg(not(windows))]
fn abs_bin(name: &str) -> String {
    for base in ["/bin", "/usr/bin"] {
        let p = format!("{base}/{name}");
        if std::path::Path::new(&p).exists() {
            return p;
        }
    }
    name.to_string()
}

/// Snapshot the set of currently-live PIDs in ONE OS call, so discovery cost stays O(1)
/// process spawns regardless of how many (possibly stale) discovery directories exist.
/// Returns `None` if enumeration fails or is empty, in which case callers fall back to the
/// per-pid `is_process_alive` (never worse than the previous behavior).
#[cfg(windows)]
fn alive_pids() -> Option<HashSet<u32>> {
    // One `tasklist` in CSV form (~60ms) lists every process; column 2 is the PID.
    let out = std::process::Command::new(system32_exe("tasklist.exe"))
        .args(["/FO", "CSV", "/NH"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&out.stdout);
    let set: HashSet<u32> = text
        .lines()
        .filter_map(|line| {
            // Rows look like: "name","pid","session","sessname","mem"
            line.split("\",\"")
                .nth(1)
                .and_then(|f| f.trim_matches('"').trim().parse::<u32>().ok())
        })
        .collect();
    (!set.is_empty()).then_some(set)
}

/// One `ps` lists every PID on both Linux and macOS (portable; `/proc` is Linux-only).
#[cfg(not(windows))]
fn alive_pids() -> Option<HashSet<u32>> {
    let out = std::process::Command::new(abs_bin("ps"))
        .args(["-A", "-o", "pid="])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&out.stdout);
    let set: HashSet<u32> = text
        .split_whitespace()
        .filter_map(|t| t.parse::<u32>().ok())
        .collect();
    (!set.is_empty()).then_some(set)
}

#[cfg(windows)]
fn is_process_alive(pid: u32) -> bool {
    use std::process::Command;
    Command::new(system32_exe("tasklist.exe"))
        .args(["/FI", &format!("PID eq {pid}"), "/NH"])
        .output()
        .is_ok_and(|o| {
            let out = String::from_utf8_lossy(&o.stdout);
            out.contains(&pid.to_string())
        })
}

#[cfg(not(windows))]
fn is_process_alive(pid: u32) -> bool {
    // Portable POSIX liveness check. `/proc` is Linux-only — on macOS it does not exist,
    // so the old `/proc/{pid}` test always returned false and the bridge filtered out every
    // discovery entry (it could find NO server on macOS). `kill -0` sends no signal but
    // succeeds iff the process exists and is signalable by us — and discovery entries are
    // our own user's processes. Works identically on macOS and Linux.
    std::process::Command::new(abs_bin("kill"))
        .args(["-0", &pid.to_string()])
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}

/// Trust a discovery directory only if it is a real directory (not a symlink), owned by the
/// current user, and not group/other-writable. Mirrors `victauri-test::discovery::dir_is_trusted`
/// — the bridge had no such check (audit #15 read-side residual), and it is the path Claude Code
/// connects through. No `unsafe` (this crate is `#![forbid(unsafe_code)]`): the effective uid is
/// read back from an exclusively-created probe file.
#[cfg(unix)]
fn dir_is_trusted(path: &std::path::Path) -> bool {
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
    let Ok(meta) = std::fs::symlink_metadata(path) else {
        return false;
    };
    if !meta.file_type().is_dir() {
        return false; // reject symlinks / non-dirs
    }
    let Some(euid) = current_euid() else {
        return false; // can't establish our uid -> don't trust
    };
    meta.uid() == euid && (meta.permissions().mode() & 0o022) == 0
}

#[cfg(unix)]
fn current_euid() -> Option<u32> {
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_PROBE: AtomicU64 = AtomicU64::new(0);
    for _ in 0..16 {
        let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed);
        let probe = std::env::temp_dir().join(format!(
            ".victauri_bridge_uidprobe_{}_{}",
            std::process::id(),
            sequence
        ));
        if let Some(uid) = uid_from_exclusive_probe(&probe) {
            return Some(uid);
        }
    }
    None
}

/// Create a UID probe without following a pre-planted symlink in the shared temp dir.
#[cfg(unix)]
fn uid_from_exclusive_probe(probe: &std::path::Path) -> Option<u32> {
    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};

    let file = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(probe)
        .ok()?;
    let uid = file.metadata().ok().map(|m| m.uid());
    drop(file);
    let _ = std::fs::remove_file(probe);
    uid
}

/// On Windows the default per-user temp dir (`%LOCALAPPDATA%\Temp`) is not world-writable, so the
/// shared-temp planting attack that `dir_is_trusted` defends against on Unix does not apply; trust
/// the directory.
///
/// HONEST CAVEAT (documented residual): this is an ASSUMPTION, not a verified ACL check. If a
/// machine redirects `TEMP`/`TMP` to a shared location (e.g. `C:\Windows\Temp` or a multi-user
/// spool), a cross-user discovery-dir planting attack is unguarded on Windows. Closing it would
/// require a Windows ownership/ACL check here (non-trivial under `#![forbid(unsafe_code)]`);
/// deferred as a non-default-config, Windows-only residual.
#[cfg(not(unix))]
fn dir_is_trusted(_path: &std::path::Path) -> bool {
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── Cold-start handshake: the bridge must answer `initialize` itself so the MCP server
    //    is connected even with no app running (the fix for the 30s handshake timeout). ──

    #[test]
    fn local_initialize_echoes_id_and_protocol_and_advertises_list_changed() {
        let client = json!({
            "jsonrpc": "2.0", "id": 7, "method": "initialize",
            "params": { "protocolVersion": "2025-03-26", "capabilities": {} }
        });
        let resp = local_initialize_response(&client);
        assert_eq!(resp["id"], 7, "must echo the client's request id");
        assert_eq!(resp["jsonrpc"], "2.0");
        let result = &resp["result"];
        // Echo the client's requested version so it is guaranteed-acceptable.
        assert_eq!(result["protocolVersion"], "2025-03-26");
        // listChanged is the keystone: it lets the client refresh its tool list when the app
        // comes up, replacing the fallback with the live set — no reconnect.
        assert_eq!(result["capabilities"]["tools"]["listChanged"], true);
        assert_eq!(result["capabilities"]["resources"]["listChanged"], true);
        // Must NOT advertise resources.subscribe — the plugin deliberately doesn't (no
        // server-initiated push exists, and this proxy has no channel to deliver it), so
        // advertising it here would mislead a client into subscribing and waiting forever.
        assert!(
            result["capabilities"]["resources"]
                .get("subscribe")
                .is_none(),
            "must not advertise a subscribe capability the server cannot honor"
        );
        assert_eq!(result["serverInfo"]["name"], "victauri-bridge");
        assert_eq!(result["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
    }

    #[test]
    fn local_initialize_falls_back_to_default_protocol_when_absent() {
        let client = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} });
        let resp = local_initialize_response(&client);
        assert_eq!(resp["result"]["protocolVersion"], DEFAULT_PROTOCOL_VERSION);
    }

    #[test]
    fn fallback_tools_covers_the_full_surface_with_valid_schemas() {
        let tools = fallback_tools();
        // The baked manifest is the whole tool surface (extracted from the plugin's #[tool]s).
        assert_eq!(
            tools.len(),
            35,
            "expected the full 35-tool fallback surface"
        );
        for t in &tools {
            assert!(t["name"].as_str().is_some_and(|n| !n.is_empty()));
            assert!(t["description"].as_str().is_some_and(|d| !d.is_empty()));
            // A permissive-but-valid JSON Schema so the client accepts the tool while down.
            assert_eq!(t["inputSchema"]["type"], "object");
        }
        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
        for expected in [
            "eval_js",
            "invoke_command",
            "query_db",
            "introspect",
            "screenshot",
        ] {
            assert!(
                names.contains(&expected),
                "fallback must include {expected}"
            );
        }
    }

    #[test]
    fn fallback_tools_response_is_a_valid_tools_list_result() {
        let resp = fallback_tools_response(&json!(42));
        assert_eq!(resp["id"], 42);
        assert!(
            resp["result"]["tools"]
                .as_array()
                .is_some_and(|a| a.len() == 35)
        );
    }

    #[test]
    fn empty_list_responses_use_the_right_result_key() {
        let is_empty_arr = |v: &Value| v.as_array().is_some_and(std::vec::Vec::is_empty);
        assert!(is_empty_arr(
            &empty_list_response("resources/list", &json!(1))["result"]["resources"]
        ));
        assert!(is_empty_arr(
            &empty_list_response("resources/templates/list", &json!(1))["result"]["resourceTemplates"]
        ));
        assert!(is_empty_arr(
            &empty_list_response("prompts/list", &json!(1))["result"]["prompts"]
        ));
    }

    #[test]
    fn unreachable_message_is_actionable() {
        let m = unreachable_message();
        // Names the cause and the one-line fix so an agent doesn't fall back to CDP.
        assert!(m.contains("tauri dev"), "must name how to start the app");
        assert!(m.to_lowercase().contains("not reachable") || m.contains("no running"));
    }

    #[test]
    fn alive_pids_enumerates_and_includes_self() {
        // The batched liveness snapshot (one OS call) keeps discovery fast even with many
        // stale discovery dirs. On a normal host it succeeds and lists our own process; if the
        // platform enumerator is somehow unavailable it returns None and callers fall back.
        if let Some(set) = alive_pids() {
            assert!(
                set.contains(&std::process::id()),
                "the live-pid snapshot must include our own running process"
            );
        }
    }

    #[cfg(unix)]
    #[test]
    fn uid_probe_refuses_preplanted_symlink_without_clobbering_target() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("target");
        let probe = dir.path().join("probe");
        std::fs::write(&target, "must-survive").unwrap();
        std::os::unix::fs::symlink(&target, &probe).unwrap();

        assert_eq!(uid_from_exclusive_probe(&probe), None);
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "must-survive");
    }

    fn srv(id: &str, name: &str, port: u16) -> ServerInfo {
        ServerInfo {
            port,
            token: None,
            identifier: Some(id.to_string()),
            product_name: Some(name.to_string()),
        }
    }

    #[test]
    fn selects_sole_server_without_app() {
        let live = vec![srv("com.a.app", "A", 7373)];
        assert!(matches!(select(&live, None), Selection::One(s) if s.port == 7373));
    }

    #[test]
    fn ambiguous_when_multiple_and_no_app() {
        let live = vec![srv("com.a.app", "A", 7373), srv("com.b.app", "B", 7374)];
        assert!(matches!(select(&live, None), Selection::Ambiguous(v) if v.len() == 2));
    }

    #[test]
    fn selects_by_identifier_among_many() {
        let live = vec![srv("com.a.app", "A", 7373), srv("com.4da.app", "4DA", 7374)];
        match select(&live, Some("com.4da.app")) {
            Selection::One(s) => assert_eq!(s.port, 7374),
            _ => panic!("should pick 4DA by identifier"),
        }
    }

    #[test]
    fn selects_by_product_name_case_insensitive() {
        let live = vec![
            srv("com.a.app", "Demo", 7373),
            srv("com.4da.app", "4DA", 7374),
        ];
        match select(&live, Some("4da")) {
            Selection::One(s) => assert_eq!(s.port, 7374),
            _ => panic!("should pick by product name"),
        }
    }

    #[test]
    fn no_match_returns_none() {
        let live = vec![srv("com.a.app", "A", 7373)];
        assert!(matches!(
            select(&live, Some("com.nope.app")),
            Selection::None
        ));
    }

    #[test]
    fn token_selection_never_crosses_ports() {
        let mut first = srv("com.a.app", "A", 7373);
        first.token = Some("token-a".to_string());
        let mut second = srv("com.b.app", "B", 7374);
        second.token = Some("token-b".to_string());
        let servers = vec![first, second];

        assert_eq!(token_for_port(&servers, 7374).as_deref(), Some("token-b"));
        assert_eq!(token_for_port(&servers, 7999), None);
    }

    #[test]
    fn substring_identifier_match() {
        let live = vec![srv("com.victauri.demo", "Demo", 7373)];
        match select(&live, Some("demo")) {
            Selection::One(s) => assert_eq!(s.port, 7373),
            _ => panic!("substring of product/identifier should match"),
        }
    }

    // End-to-end against REAL discovery files: the plugin writes port/token/metadata.json
    // under `<temp>/victauri/<pid>/`; this proves the bridge parses those real files and can
    // select the right app by identity — even amid the many stale dirs left by dead processes.
    #[test]
    fn discover_servers_reads_real_metadata_and_selects() {
        let pid = std::process::id(); // alive → passes is_process_alive
        let dir = std::env::temp_dir().join("victauri").join(pid.to_string());
        std::fs::create_dir_all(&dir).unwrap();
        // Make ownership/permissions deterministic so `dir_is_trusted` passes regardless of
        // the runner's umask (a umask of 002 would otherwise leave the dir group-writable
        // and the read-side trust guard would correctly reject it).
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
        }
        std::fs::write(dir.join("port"), "61999").unwrap();
        std::fs::write(dir.join("token"), "tok-xyz").unwrap();
        std::fs::write(
            dir.join("metadata.json"),
            r#"{"pid":1,"port":61999,"identifier":"com.test.discover","product_name":"DiscoverTest"}"#,
        )
        .unwrap();

        let servers = discover_servers();
        let mine = servers
            .iter()
            .find(|s| s.identifier.as_deref() == Some("com.test.discover"))
            .expect("bridge should discover the entry written for the live current pid");
        assert_eq!(mine.port, 61999);
        assert_eq!(mine.token.as_deref(), Some("tok-xyz"));
        assert_eq!(mine.product_name.as_deref(), Some("DiscoverTest"));

        // And selection by identity picks it out.
        assert!(matches!(
            select(std::slice::from_ref(mine), Some("com.test.discover")),
            Selection::One(_)
        ));

        let _ = std::fs::remove_dir_all(&dir);
    }

    // Audit #15 read-side: a planted, world-writable discovery dir (the shape an attacker
    // creates in a shared /tmp) must NOT be trusted, so its token is never read/sent.
    #[cfg(unix)]
    #[test]
    fn dir_is_trusted_rejects_world_writable_and_symlink() {
        use std::os::unix::fs::PermissionsExt;
        let base = std::env::temp_dir().join(format!("vic_trust_test_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        std::fs::create_dir_all(&base).unwrap();

        // Owned, 0700 -> trusted.
        let good = base.join("good");
        std::fs::create_dir_all(&good).unwrap();
        std::fs::set_permissions(&good, std::fs::Permissions::from_mode(0o700)).unwrap();
        assert!(dir_is_trusted(&good), "0700 owner dir must be trusted");

        // Group/other-writable -> rejected.
        let bad = base.join("bad");
        std::fs::create_dir_all(&bad).unwrap();
        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
        assert!(!dir_is_trusted(&bad), "world-writable dir must be rejected");

        // Symlink (even to a trusted target) -> rejected (no symlink following).
        let link = base.join("link");
        let _ = std::os::unix::fs::symlink(&good, &link);
        assert!(!dir_is_trusted(&link), "symlinked dir must be rejected");

        let _ = std::fs::remove_dir_all(&base);
    }

    // Round-4 audit, blocker #3 (CLI empty-token fallback): a blank `VICTAURI_AUTH_TOKEN`
    // must be treated as "unset" so the bridge falls through to the discovered token, NOT
    // sent as an empty Bearer to an auth-enabled server (which would 401 every call).
    #[test]
    fn normalize_env_token_treats_blank_as_unset() {
        assert_eq!(normalize_env_token(None), None, "unset -> None");
        assert_eq!(
            normalize_env_token(Some(String::new())),
            None,
            "empty -> None"
        );
        assert_eq!(
            normalize_env_token(Some("   ".to_string())),
            None,
            "spaces -> None"
        );
        assert_eq!(
            normalize_env_token(Some("\t\r\n ".to_string())),
            None,
            "whitespace -> None"
        );
        assert_eq!(
            normalize_env_token(Some("real-token".to_string())).as_deref(),
            Some("real-token"),
            "real token preserved"
        );
        assert_eq!(
            normalize_env_token(Some("  padded  ".to_string())).as_deref(),
            Some("padded"),
            "surrounding whitespace trimmed"
        );
    }
}