trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
//! Daemon URL resolution: explicit flag → lock file → default.
//!
//! Why: The daemon may bind to an ephemeral port when 7880 is busy.
//! The lock file records the actual address so clients always find it.
//! What: `resolve_daemon_url` checks an explicit override first, then
//! reads `~/.trusty-mpm/daemon.lock`, then falls back to the hard-coded
//! default. `resolve_daemon_url_via_gateway` additionally tries the
//! trusty-console gateway first so all traffic can flow through the
//! unified web UI when it is running.
//!
//! Issue #2487: every resolver here takes `explicit: Option<&str>` and MUST
//! be fed by a caller that only produces `Some` when the operator actually
//! supplied `--url` / `TRUSTY_MPM_URL` — never by a clap `default_value`
//! string. Prior to #2487 the resolvers tried to reconstruct that distinction
//! themselves by comparing the value against [`DEFAULT_DAEMON_URL`], which
//! silently broke whenever an operator's real override happened to equal the
//! default (`TRUSTY_MPM_URL=http://127.0.0.1:7880`, itself the documented
//! default) — the value looked identical to "nothing was passed", so
//! `resolve_daemon_url_via_gateway` fell through to the trusty-console
//! gateway probe and rerouted traffic through the console proxy (port 7788)
//! instead of the daemon (port 7880). The fix: every CLI-facing `url` field
//! is `Option<String>` with no `default_value` (clap only fills `Some` on a
//! real flag/env hit), and [`explicit_url_from_env`] is the single shared
//! helper for the few call sites that read `TRUSTY_MPM_URL` before clap has
//! even parsed (`tm projects` bare-TUI interception). No resolver in this
//! module compares `explicit` against `DEFAULT_DAEMON_URL` anymore.
//! Test: The unit tests below cover all resolution paths, including the
//! gateway probe, explicit-override bypass (including a value equal to the
//! default), and direct fallback.
//!
//! Issue #1737: probing and fallback are two SEPARATE concerns that must not
//! be conflated. An IMPLICIT URL (nothing passed, or a stale env var) may
//! fall back through the lock file to the default — that is the existing,
//! desired #1731 behaviour. An EXPLICIT URL (operator supplied `--url` /
//! `TRUSTY_MPM_URL`) must never silently fall back: if it is unreachable, the
//! caller must be told, not redirected to a different daemon.
//! [`resolve_daemon_url_probing`] now returns `Result<String, DaemonUrlError>`
//! — `Err` only for an explicit, probed, unreachable URL; every implicit path
//! still returns `Ok`. [`resolve_daemon_url_for_cli`] wraps that policy for a
//! caller that wants it.
//!
//! `main.rs`'s top-level dispatch and `commands::projects::launch_bare_tui`
//! deliberately keep calling the INFALLIBLE `resolve_daemon_url_via_gateway`
//! directly, unchanged — several dispatch targets reached through that
//! shared top-level `url` (the bare `tm` guided default's OTHER code paths,
//! `tm hook`) have an intentional fail-open contract that must survive an
//! unreachable URL (see `tests/tm_hook_idle_parking.rs`). The one confirmed
//! LIVE instance of the #1737 bug — `commands::guided::run_guided_default`
//! silently auto-starting/reconnecting to a completely different, real
//! daemon when an explicit `--url` was unreachable — calls
//! [`resolve_daemon_url_for_cli`] directly, itself, before any of its
//! fail-open fallback logic runs; see that function's module for the guard.

use std::path::PathBuf;

use crate::core::paths::FRAMEWORK_DIR_NAME;

/// Canonical loopback bind address the daemon listens on by default.
///
/// Why (issue #1268): the daemon's `--addr` default, the thin CLI's `--url`
/// default, and [`DEFAULT_DAEMON_URL`] previously each hard-coded the
/// `127.0.0.1:7880` literal independently. Any one of them drifting (or an
/// operator carrying a stale `TRUSTY_MPM_URL=…:7881` in their environment)
/// produced "daemon: unreachable" because the client probed a port the daemon
/// never bound. Hoisting the port into a single constant — and deriving every
/// other default address string from it — makes the bind side and the client
/// side provably agree from one source of truth.
/// What: the literal `"127.0.0.1:7880"`, parsed into a `SocketAddr` by
/// [`default_daemon_addr`] and embedded into [`DEFAULT_DAEMON_URL`].
/// Test: `default_url_matches_addr`, `default_addr_parses`.
pub const DEFAULT_DAEMON_ADDR: &str = "127.0.0.1:7880";

/// Default daemon URL when no override and no lock file is found.
///
/// Why: derived from [`DEFAULT_DAEMON_ADDR`] so the client default and the
/// daemon bind default can never drift (issue #1268).
/// What: `"http://127.0.0.1:7880"` — the `http://` scheme prepended to
/// [`DEFAULT_DAEMON_ADDR`]. Kept as a `&'static str` literal (rather than a
/// runtime `format!`) so it remains usable in `const`/`default_value` contexts;
/// the `default_url_matches_addr` test guarantees the two stay in lockstep.
/// Test: `default_url_matches_addr`.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7880";

/// Default trusty-console HTTP address (host:port) used when the console's
/// discovery file is absent or unreadable.
///
/// Why: trusty-console defaults to 127.0.0.1:7788; mirroring that here lets
/// the gateway resolver fall back to the expected address without importing
/// trusty-console (which would create a circular dependency between crates).
/// What: the literal `"127.0.0.1:7788"`, matching `trusty_console::DEFAULT_HTTP`.
/// Test: `resolve_via_gateway_falls_back_to_direct_on_probe_failure`.
pub const DEFAULT_CONSOLE_ADDR: &str = "127.0.0.1:7788";

/// Path at which the trusty-console proxies the MPM daemon.
///
/// Why: a single constant prevents the gateway path `/api/mpm` from being
/// hard-coded in both the resolver and any caller that needs to detect whether
/// a resolved URL is routing via the gateway (e.g. `tm health` display).
/// What: the literal `"/api/mpm"` — appended to the console base URL to form
/// the gateway base, e.g. `http://127.0.0.1:7788/api/mpm`.
/// Test: `resolve_via_gateway_uses_gateway_when_probe_succeeds`.
pub const GATEWAY_PATH: &str = "/api/mpm";

/// Parse [`DEFAULT_DAEMON_ADDR`] into a `SocketAddr`.
///
/// Why: the daemon's clap `--addr` argument is typed as `SocketAddr`; this
/// helper lets that default be derived from the shared [`DEFAULT_DAEMON_ADDR`]
/// constant instead of repeating the literal (issue #1268).
/// What: parses the constant; the parse is infallible for a well-formed
/// literal, so a malformed constant is a programmer error caught by
/// `default_addr_parses` at test time.
/// Test: `default_addr_parses`.
pub fn default_daemon_addr() -> std::net::SocketAddr {
    DEFAULT_DAEMON_ADDR
        .parse()
        .expect("DEFAULT_DAEMON_ADDR is a valid SocketAddr literal")
}

/// Path to the daemon lock file.
///
/// Why: the lock file MUST live in the same `~/.trusty-mpm` root as every
/// other framework artifact (logs, sessions, framework dir). It previously
/// resolved under `dirs::config_dir()` (`~/.config/trusty-mpm`), so the daemon
/// wrote the lock to one directory while the rest of the app — and any user
/// inspecting the install — looked in another. That mismatch meant clients
/// that resolved the URL from a differently-configured environment (or simply
/// expected the documented `~/.trusty-mpm` location) never found the lock and
/// fell back to the default port, reporting "daemon unreachable".
/// What: `~/.trusty-mpm/daemon.lock`, derived from the same `home_dir` +
/// [`FRAMEWORK_DIR_NAME`] as `FrameworkPaths`.
/// Test: `lock_file_path_is_under_framework_root`.
pub fn lock_file_path() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(FRAMEWORK_DIR_NAME)
        .join("daemon.lock")
}

/// Read `TRUSTY_MPM_URL` directly from the process environment.
///
/// Why (#2487): clap's `env = "TRUSTY_MPM_URL"` attribute on an
/// `Option<String>` field is the normal way a `tm` subcommand picks up the
/// override, but a couple of call sites resolve the daemon URL BEFORE
/// `Cli::try_parse()` runs — namely `tm projects` bare-TUI interception
/// (`commands::projects::launch_bare_tui`), which must fire ahead of clap so
/// the exact clap "requires a subcommand" usage error is preserved for a
/// non-interactive bare invocation (see that function's doc). Those sites
/// need the identical "did the operator actually set this" semantics as
/// every clap-parsed field, so they call this helper instead of
/// hand-rolling their own `std::env::var` read (and risking a different
/// empty-string or trim rule than the rest of the resolvers).
/// What: `std::env::var("TRUSTY_MPM_URL")`, trimmed, with an empty result
/// treated the same as "unset" (`None`) — matching every `resolve_daemon_url*`
/// empty-string check.
/// Test: `explicit_url_from_env_reads_set_var`,
/// `explicit_url_from_env_treats_empty_as_unset`.
pub fn explicit_url_from_env() -> Option<String> {
    std::env::var("TRUSTY_MPM_URL")
        .ok()
        .filter(|v| !v.trim().is_empty())
}

/// Resolve the daemon URL in priority order:
/// 1. `explicit` — from `--url` flag or `TRUSTY_MPM_URL` env var (`Some`,
///    non-empty). Every CLI-facing `url` field is `Option<String>` with NO
///    `default_value` (issue #2487) — clap only produces `Some` when the flag
///    or env var was actually supplied, so `Some` here always means the
///    operator made a real, deliberate choice, even when that choice happens
///    to equal [`DEFAULT_DAEMON_URL`] verbatim. There is no longer a
///    string-equality heuristic trying to guess intent from the value.
/// 2. Lock file `~/.trusty-mpm/daemon.lock` (if present and PID alive)
/// 3. `DEFAULT_DAEMON_URL` — except in an [`isolated_environment`], which
///    gets [`ISOLATED_NO_DAEMON_URL`] instead (#9556; see
///    [`try_resolve_daemon_url`]).
pub fn resolve_daemon_url(explicit: Option<&str>) -> String {
    try_resolve_daemon_url(explicit).unwrap_or_else(isolated_refusal_url)
}

/// [`resolve_daemon_url`], reporting an isolated miss as an error.
///
/// Why (#9556): a sandbox whose own daemon died has its dead-pid lock deleted,
/// so resolution fell through to [`DEFAULT_DAEMON_URL`] — the live fleet.
/// What: explicit (non-empty) → lock file → [`DEFAULT_DAEMON_URL`], except
/// that in an [`isolated_environment`] the last step is
/// `Err(DaemonUrlError::NoSandboxDaemon)`. Reads one file; never probes.
/// Test: `resolve_refuses_default_daemon_under_isolation`,
/// `resolve_keeps_default_daemon_outside_isolation`.
pub fn try_resolve_daemon_url(explicit: Option<&str>) -> Result<String, DaemonUrlError> {
    // 1. Explicit override always wins when present and non-empty — the
    //    caller (a CLI field or the shared env-read helper) already collapsed
    //    "not supplied" to `None`, so there is nothing left to disambiguate.
    if let Some(url) = explicit
        && !url.is_empty()
    {
        return Ok(url.to_string());
    }

    // 2. Lock file — records the actual bound address written by the daemon.
    //    #9556: filtered, so an isolated process never reads the host's lock.
    if let Some(url) = client_lock_url() {
        return Ok(url);
    }

    // #9556: the default is the host's daemon; an isolated process refuses it.
    if isolated_environment() {
        return Err(DaemonUrlError::NoSandboxDaemon);
    }

    // 3. Fall back to the compiled-in default.
    Ok(DEFAULT_DAEMON_URL.to_string())
}

/// The daemon lock file's URL as this process may use it (#9556).
///
/// Why: every reader of the lock — the resolver, the `tm` banner and the
/// statusline — must agree that an isolated process never sees the host
/// daemon's lock, or the banner shows the live 127.0.0.1:7880 as "online".
/// What: the lock's address (product magic and a live pid, as
/// `daemon_identity::read_lock` checks), dropped when the process is in an
/// [`isolated_environment`] and the lock belongs to the host by
/// [`lock_is_hosts`]. Outside isolation it is exactly `read_lock`'s address,
/// and no home lookup runs.
/// Test: `client_lock_url_ignores_a_host_lock_under_isolation`,
/// `client_lock_url_is_read_lock_outside_isolation`,
/// `isolated_resolver_ignores_a_host_lock_naming_the_default`,
/// `isolated_resolver_keeps_a_sandbox_home_lock`.
pub fn client_lock_url() -> Option<String> {
    let url = read_lock_file_url()?;
    // #9556: the home lookups run only when isolated.
    if isolated_environment() {
        let home = dirs::home_dir();
        let passwd_home = crate::core::host_state_gate::passwd_home_dir();
        if lock_is_hosts(&url, home.as_deref(), passwd_home.as_deref()) {
            return None;
        }
    }
    Some(url)
}

/// Whether a daemon lock read from `home` belongs to the host (#9556).
///
/// Why: an isolated process that inherited the operator's `HOME` reads the
/// host daemon's lock, which names the live 127.0.0.1:7880.
/// What: `true` when the lock names [`DEFAULT_DAEMON_URL`], when either home
/// is unknown, or when `home` and the password-database home resolve to the
/// same directory — the comparison `tm daemon --sandbox` makes before it
/// starts.
/// Test: `host_lock_is_judged_by_address_and_home`,
/// `isolated_resolver_ignores_a_host_lock_naming_the_default`,
/// `isolated_resolver_keeps_a_sandbox_home_lock`.
fn lock_is_hosts(
    lock_url: &str,
    home: Option<&std::path::Path>,
    passwd_home: Option<&std::path::Path>,
) -> bool {
    if lock_url.trim_end_matches('/') == DEFAULT_DAEMON_URL {
        return true;
    }
    let (Some(home), Some(account)) = (home, passwd_home) else {
        return true;
    };
    let resolve =
        |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    resolve(home) == resolve(account)
}

/// The base URL an infallible resolver hands out when an isolated process has
/// no daemon of its own (#9556).
///
/// Why: [`resolve_daemon_url`] and [`resolve_daemon_url_via_gateway`] return a
/// `String`, and their callers include fail-open paths (`tm hook`). They must
/// still never answer [`DEFAULT_DAEMON_URL`] in a sandbox.
/// What: a non-HTTP scheme. `reqwest` rejects it before any DNS or TCP work
/// ("builder error … URL scheme is not allowed"), and the error names this
/// URL, which states the remedy.
/// Test: `resolve_refuses_default_daemon_under_isolation`.
pub const ISOLATED_NO_DAEMON_URL: &str = "tm-sandbox-no-daemon://set-TRUSTY_MPM_URL";

/// Turn [`ISOLATED_NO_DAEMON_URL`] back into the error it stands for (#9556).
///
/// Why: a caller holding an infallible resolver's answer (bare `tm`'s guided
/// default) must stop with the sandbox message, not misread the refused
/// request as a slow daemon.
/// What: `Err(DaemonUrlError::NoSandboxDaemon)` for the sentinel, else `Ok(())`.
/// Test: `isolated_no_daemon_url_maps_back_to_the_refusal`.
pub fn refuse_isolated_no_daemon(url: &str) -> Result<(), DaemonUrlError> {
    if url == ISOLATED_NO_DAEMON_URL {
        Err(DaemonUrlError::NoSandboxDaemon)
    } else {
        Ok(())
    }
}

/// Map an isolated refusal onto [`ISOLATED_NO_DAEMON_URL`], logging why.
fn isolated_refusal_url(err: DaemonUrlError) -> String {
    tracing::warn!("{err}");
    ISOLATED_NO_DAEMON_URL.to_string()
}

/// Refuse a client-side daemon spawn in an [`isolated_environment`] (#9556).
///
/// Why: `tm start` and the bare-`tm` autostart spawn a plain `tm daemon`, or
/// kickstart the host's launchd job, when nothing answers. In a sandbox that
/// daemon would bind the default 127.0.0.1:7880 (or fall back to an ephemeral
/// port) and write its lock under the inherited `HOME`; the launchd job is the
/// live daemon itself. The only sanctioned sandbox daemon is
/// `scripts/sandbox_daemon.sh` (#9121).
/// What: `Err(DaemonUrlError::NoSandboxDaemon)` when isolated, else `Ok(())`.
/// Test: `daemon_spawn_refused_only_under_isolation`.
pub fn refuse_daemon_spawn_when_isolated() -> Result<(), DaemonUrlError> {
    if isolated_environment() {
        Err(DaemonUrlError::NoSandboxDaemon)
    } else {
        Ok(())
    }
}

/// Error returned when an operator-supplied daemon URL cannot be reached.
///
/// Why (issue #1737): an explicit `--url` / `TRUSTY_MPM_URL` is a deliberate,
/// specific choice — often used to target a non-default or intentionally
/// isolated daemon (e.g. in tests: `--url http://127.0.0.1:1`). Silently
/// falling back to the lock file or compiled-in default on probe failure
/// means `tm` can end up operating against a DIFFERENT daemon than the one
/// the operator named, which is surprising and potentially dangerous for
/// destructive operations (e.g. `tm session prune-idle`). This error type
/// lets callers stop instead of guessing.
/// What: a single `Unreachable` variant carrying the URL that failed the
/// health probe, formatted into a message that names the URL and states
/// plainly that no fallback was attempted.
/// Test: `probing_resolver_errors_when_explicit_unreachable`,
/// `resolve_for_cli_explicit_unreachable_errors`.
// #9556 (owner ruling 2026-10-09 17:49Z): non-exhaustive, so a later refusal
// kind is not another breaking change.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum DaemonUrlError {
    /// The explicitly-supplied URL failed a `GET {url}/health` probe.
    #[error(
        "daemon unreachable at explicitly configured URL {url} (probed GET {url}/health); \
         refusing to fall back to a different daemon — check the address or drop --url/TRUSTY_MPM_URL \
         to use automatic discovery"
    )]
    Unreachable {
        /// The URL that was probed and found unreachable.
        url: String,
    },
    /// #9556: an isolated process found no explicit URL, no recorded console
    /// and no live lock, and refuses the host's default daemon.
    #[error(
        "no daemon reachable for this sandbox: TRUSTY_SANDBOX=1 or TRUSTY_DATA_DIR_OVERRIDE is \
         set, and no --url/TRUSTY_MPM_URL, recorded console or live daemon lock names one; \
         refusing the host default daemon (127.0.0.1:7880) — start one with \
         scripts/sandbox_daemon.sh and pass its URL"
    )]
    NoSandboxDaemon,
}

/// Process exit code for a [`DaemonUrlError::Unreachable`] at the CLI boundary.
///
/// Why (issue #1737): shares the exact same value as the pre-existing
/// `commands::prune::EXIT_SM_UNAVAILABLE` (#1313) — both mean "the thing you
/// asked for is unavailable, not a generic failure" and want callers/skills
/// to branch on one stable, non-1 code instead of parsing stderr. Sourced
/// from [`crate::core::exit_codes::EXIT_UNAVAILABLE`], the single shared
/// constant, rather than redefining the literal `75` a second time —
/// `commands::prune::EXIT_SM_UNAVAILABLE` sources from the same constant, so
/// the two can never drift apart.
/// What: `75`.
/// Test: `exit_code_matches_sm_unavailable_convention`.
pub const EXIT_DAEMON_URL_UNREACHABLE: i32 = crate::core::exit_codes::EXIT_UNAVAILABLE;

/// Resolve the daemon URL with reachability probing for explicit overrides.
///
/// Why: `resolve_daemon_url` treats an explicit non-default URL as
/// unconditionally winning without ever checking it is actually reachable.
/// That is correct for a stale, IMPLICIT `TRUSTY_MPM_URL` picked up from the
/// environment (#1731) — the lock file (which records the daemon's actual
/// bound address) should still be consulted so a forgotten env var does not
/// permanently break `tm`. It is WRONG for an explicit, operator-supplied
/// `--url` / `TRUSTY_MPM_URL`: silently retargeting a different daemon on
/// probe failure means a deliberately-unreachable address (used, for example,
/// to assert "no daemon is running" in a test) instead connects to whatever
/// daemon the lock file or default happens to point at (#1737). This function
/// therefore probes the explicit URL and, on failure, ERRORS rather than
/// falling back — there is no implicit/explicit ambiguity to resolve because
/// every caller of this function is expected to pass `explicit` only when the
/// operator truly supplied `--url`/`TRUSTY_MPM_URL` (the `Option::is_some`
/// discipline established by #2487).
/// What:
///
/// 1. `explicit` (non-empty) — probed; `Ok(url)` if reachable, otherwise
///    `Err(DaemonUrlError::Unreachable)`. No fallback is attempted.
/// 2. No explicit override (`None` or empty) — delegates to the synchronous
///    [`resolve_daemon_url`], preserving the full implicit fallback chain
///    (lock file → [`DEFAULT_DAEMON_URL`]) exactly as before.
///
/// Test: `probing_resolver_errors_when_explicit_unreachable` and
/// `probing_resolver_wins_when_explicit_reachable` below.
pub async fn resolve_daemon_url_probing(
    client: &reqwest::Client,
    explicit: Option<&str>,
) -> Result<String, DaemonUrlError> {
    // Step 1: if the caller supplied a real override (non-empty), probe it.
    // On failure, error out immediately — no fallback to lock file / default.
    if let Some(url) = explicit
        && !url.is_empty()
    {
        return if probe_url(client, url).await {
            Ok(url.to_string())
        } else {
            Err(DaemonUrlError::Unreachable {
                url: url.to_string(),
            })
        };
    }

    // No explicit override: lock file → default; #9556: an isolated process
    // gets `Err(NoSandboxDaemon)` instead of the default.
    try_resolve_daemon_url(explicit)
}

/// Resolve the daemon URL for a CLI command that wants strict
/// explicit-URL-must-be-reachable semantics (issue #1737).
///
/// Why: a command that operates against a SPECIFIC daemon on the operator's
/// behalf (destructive session-management ops are the concrete #1737
/// example) should never silently retarget a different daemon when the
/// address it was told to use turns out to be unreachable — that risks
/// operating against the wrong fleet. This helper gives such a command a
/// single call combining the two-tier policy: an operator-supplied `--url` /
/// `TRUSTY_MPM_URL` is verified reachable and used verbatim, erroring rather
/// than falling back on failure; an unset URL keeps the full implicit
/// fallback chain (trusty-console gateway → lock file → default) that #1731
/// and #1849 built.
///
/// NOT wired into `main.rs`'s blanket top-level dispatch — `cli.url` is
/// still resolved once, unconditionally, via the infallible
/// `resolve_daemon_url_via_gateway` (main.rs, right before the big command
/// `match`), because `tm hook` has an intentional FAIL-OPEN contract that
/// must survive an unreachable explicit URL (a Claude Code hook must never
/// fail the user's turn over a best-effort daemon POST — see
/// `tests/tm_hook_idle_parking.rs`, which asserts exit 0 even for
/// `--url http://127.0.0.1:1`). A command that wants the strict contract
/// calls this itself, on top of that shared resolution, before running its
/// own fallback-prone logic — `commands::guided::run_guided_default` does
/// exactly this: it was the one LIVE, empirically-confirmed instance of the
/// #1737 bug (its `ensure_daemon_started` auto-start step ignores whatever
/// URL it is handed and always re-resolves IMPLICITLY once some daemon is
/// up, so an unreachable EXPLICIT `--url` silently ended up auto-starting/
/// reconnecting to a completely different, real daemon and showing ITS live
/// session list). See that function's guard at its top for the wiring.
/// What: when `explicit` is `Some` and non-empty, probes it alone via
/// [`resolve_daemon_url_probing`] and returns its `Result` untouched (`Err`
/// on unreachable, no fallback). Otherwise delegates to the infallible
/// [`resolve_daemon_url_via_gateway`] (gateway probe → lock file → default),
/// wrapped in `Ok`.
/// Test: `resolve_for_cli_explicit_unreachable_errors`,
/// `resolve_for_cli_implicit_falls_back`; end-to-end wiring covered by
/// `tests/tm_guided_default_explicit_url.rs`.
pub async fn resolve_daemon_url_for_cli(
    client: &reqwest::Client,
    explicit: Option<&str>,
) -> Result<String, DaemonUrlError> {
    if explicit.is_some_and(|url| !url.is_empty()) {
        resolve_daemon_url_probing(client, explicit).await
    } else {
        // #9556: an isolated miss is an error here, not the default daemon.
        try_resolve_daemon_url_via_gateway(client, explicit).await
    }
}

/// Resolve the daemon URL routing through the trusty-console gateway when
/// possible, with seamless fallback to the direct daemon when the console is
/// not running.
///
/// Why: when the trusty-console is up it proxies `ANY /api/mpm/{path}` to the
/// MPM daemon, so all `tm` traffic can flow through the unified web UI — with
/// future benefits like audit logging and auth. Direct fallback (lock file →
/// default) ensures `tm` still works when the console is not running, with no
/// operator action required. The gateway-first approach is gated on a live
/// probe so a stale console discovery file never breaks the CLI.
///
/// Precedence:
///   a) Explicit user override (non-empty `--url` / `TRUSTY_MPM_URL`) → use it
///      directly; the operator chose a specific address, so the gateway is
///      bypassed entirely. As of #2487 this is a plain `Option::is_some`
///      check — a value that happens to equal [`DEFAULT_DAEMON_URL`] still
///      wins, because callers only pass `Some` when the flag/env var was
///      actually supplied (see [`resolve_daemon_url`]'s doc). Before #2487
///      this step also compared the value against `DEFAULT_DAEMON_URL` to
///      guess whether it was "real"; that heuristic is exactly what let
///      `TRUSTY_MPM_URL=http://127.0.0.1:7880` (the literal default) fall
///      through to the gateway probe below and get silently rerouted through
///      the trusty-console proxy on a different port.
///   b) Gateway probe: read the console address from its discovery file
///      (falling back to `DEFAULT_CONSOLE_ADDR`). Construct
///      `http://{console}/api/mpm` and probe `GET .../health` with a 500 ms
///      timeout. If it returns 200 → return the gateway base URL. #9556: under
///      `TRUSTY_SANDBOX=1` or `TRUSTY_DATA_DIR_OVERRIDE` with no recorded
///      console, this step is skipped — the default is the host's console.
///   c) Direct fallback: delegate to `resolve_daemon_url_probing` (lock file →
///      `DEFAULT_DAEMON_URL`). This path is taken whenever the console is
///      absent or the gateway probe fails.
///
/// Test: `resolve_via_gateway_explicit_override_wins`,
///       `resolve_via_gateway_explicit_override_equal_to_default_still_wins`,
///       `resolve_via_gateway_uses_gateway_when_probe_succeeds`,
///       `resolve_via_gateway_falls_back_to_direct_on_probe_failure`.
/// Ceiling on the console-gateway `/health` probe every `tm` command pays.
///
/// Why: named because `main.rs` resolves the daemon URL through this probe
/// before dispatching ANY command, so it is a fixed prefix on every
/// invocation's wall clock — including `tm hook --pm-guard`, whose registered
/// command carries no `--url` and therefore always reaches the probe. #7975
/// sizes the guard's own budgets against it.
/// What: 500 ms, the client-level bound on the dedicated probe client below.
/// Test: `guard_read_budget_leaves_the_audit_post_inside_the_hook_timeout`
/// (in the `tm` binary) is what fails if this is raised past the guard's
/// headroom.
pub const GATEWAY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);

pub async fn resolve_daemon_url_via_gateway(
    client: &reqwest::Client,
    explicit: Option<&str>,
) -> String {
    try_resolve_daemon_url_via_gateway(client, explicit)
        .await
        .unwrap_or_else(isolated_refusal_url)
}

/// [`resolve_daemon_url_via_gateway`], reporting an isolated miss as an error
/// (#9556) — the fallible form [`resolve_daemon_url_for_cli`] returns.
async fn try_resolve_daemon_url_via_gateway(
    client: &reqwest::Client,
    explicit: Option<&str>,
) -> Result<String, DaemonUrlError> {
    let recorded = trusty_common::read_daemon_addr("trusty-console")
        .ok()
        .flatten();
    resolve_daemon_url_via_gateway_from(client, explicit, recorded, DEFAULT_CONSOLE_ADDR).await
}

/// [`resolve_daemon_url_via_gateway`] with the console discovery read and the
/// default console address injected.
///
/// Why (#9556): the default console is the live trusty-console. A sandboxed
/// `tm` that probed it was routed through the live console to the live
/// daemon. The default is injected so a test can substitute a listener.
/// What: `recorded` is the console's discovery-file address. A blank or
/// absent one falls back to `default_console`, except in an
/// [`isolated_environment`], where the gateway is skipped and
/// [`resolve_daemon_url`] answers without a probe. Otherwise the explicit →
/// gateway → direct precedence of [`resolve_daemon_url_via_gateway_inner`]
/// applies.
/// Test: `gateway_probe_skipped_under_data_dir_override`,
/// `gateway_probe_skipped_under_sandbox_flag`,
/// `gateway_probe_uses_recorded_addr_under_data_dir_override`,
/// `gateway_probe_uses_default_console_outside_isolation`.
async fn resolve_daemon_url_via_gateway_from(
    client: &reqwest::Client,
    explicit: Option<&str>,
    recorded: Option<String>,
    default_console: &str,
) -> Result<String, DaemonUrlError> {
    let console_addr = match recorded_console_addr(recorded) {
        Some(addr) => addr,
        // #9556: an isolated client never falls back to the host's console.
        None if isolated_environment() => return try_resolve_daemon_url(explicit),
        None => default_console.to_string(),
    };
    resolve_daemon_url_via_gateway_inner(client, explicit, &console_addr).await
}

/// Whether this process runs isolated from the host's trusty-* services.
///
/// Why (#9556): `scripts/sandbox_daemon.sh` sets both variables; either one
/// alone means the operator asked to stay off the host's default endpoints.
/// What: `true` when `TRUSTY_SANDBOX` is exactly `1` (the rule
/// `trusty_common::credentials::sandbox_flag_set` owns) or
/// `TRUSTY_DATA_DIR_OVERRIDE` is set and non-empty.
/// Test: `gateway_probe_skipped_under_data_dir_override`,
/// `gateway_probe_skipped_under_sandbox_flag`.
pub fn isolated_environment() -> bool {
    use trusty_common::credentials::{SANDBOX_ENV_VAR, sandbox_flag_set};
    sandbox_flag_set(std::env::var_os(SANDBOX_ENV_VAR).as_deref())
        || std::env::var_os(trusty_common::DATA_DIR_OVERRIDE_ENV).is_some_and(|v| !v.is_empty())
}

/// The console address a discovery-file read recorded, trimmed; `None` when
/// absent or blank.
fn recorded_console_addr(recorded: Option<String>) -> Option<String> {
    recorded
        .map(|a| a.trim().to_string())
        .filter(|a| !a.is_empty())
}

/// Resolve the `host:port` the trusty-console is selected to serve on.
///
/// Why: two callers need the console's selected port — the gateway resolver
/// above, and the `tm statusline` version segment, which links to the
/// dashboard. This repo's common-entry-point rule makes a second copy of the
/// discovery-file read a defect, so the read lives here once and both callers
/// route through it.
/// What: reads the `trusty-console` discovery file via
/// [`trusty_common::read_daemon_addr`]; an absent, unreadable, or blank file
/// falls back to [`DEFAULT_CONSOLE_ADDR`]. Never errors, never probes the
/// network, and blocks on nothing but one small file read — the statusline
/// render path tolerates none of those.
/// Test: `console_addr_from_prefers_the_recorded_addr`,
/// `console_addr_from_falls_back_when_absent_or_blank`.
pub fn console_addr() -> String {
    console_addr_from(
        trusty_common::read_daemon_addr("trusty-console")
            .ok()
            .flatten(),
    )
}

/// Pure core of [`console_addr`] with the discovery-file read injected.
///
/// Why: mirrors [`resolve_daemon_url_via_gateway_inner`] — the fallback rule is
/// the part worth testing, and testing it should not require stubbing `$HOME`.
/// What: returns `recorded` trimmed when it is present and non-blank, else
/// [`DEFAULT_CONSOLE_ADDR`].
/// Test: `console_addr_from_prefers_the_recorded_addr`,
/// `console_addr_from_falls_back_when_absent_or_blank`.
fn console_addr_from(recorded: Option<String>) -> String {
    recorded_console_addr(recorded).unwrap_or_else(|| DEFAULT_CONSOLE_ADDR.to_string())
}

/// The console address a client may contact, or `None` when it must not
/// contact one.
///
/// Why (#9556): the `tm` banner TCP-probes the console; under isolation with
/// no recorded console, the default is the host's live console.
/// What: [`console_addr_to_probe_from`] over the discovery-file read and
/// [`isolated_environment`].
/// Test: `console_probe_target_is_none_under_isolation`.
pub fn console_addr_to_probe() -> Option<String> {
    console_addr_to_probe_from(
        trusty_common::read_daemon_addr("trusty-console")
            .ok()
            .flatten(),
        isolated_environment(),
    )
}

/// Pure core of [`console_addr_to_probe`]: the recorded address, else the
/// default — except under isolation, where there is none (#9556).
/// Test: `console_probe_target_is_none_under_isolation`.
fn console_addr_to_probe_from(recorded: Option<String>, isolated: bool) -> Option<String> {
    recorded_console_addr(recorded)
        .or_else(|| (!isolated).then(|| DEFAULT_CONSOLE_ADDR.to_string()))
}

/// Build the trusty-console's base URL on its selected port.
///
/// Why: the `tm statusline` version segment is a clickable link to the
/// dashboard, and the link target must name the port the console is actually
/// configured for.
/// What: `http://{console_addr()}`. Plain HTTP because the console serves
/// loopback by default (ADR-0018); a console bound elsewhere records that
/// address in the same discovery file, so the host comes from there too.
/// Test: `console_base_url_wraps_the_resolved_addr`.
pub fn console_base_url() -> String {
    format!("http://{}", console_addr())
}

/// Inner implementation of the gateway resolver with an injected console addr.
///
/// Why: separates the file-system read (`read_daemon_addr`) from the probe
/// logic so tests can supply a controlled address without touching disk or
/// manipulating `$HOME`.
/// What: implements the three-step precedence (explicit override → gateway
/// probe → direct fallback) using the caller-supplied `console_addr`.
/// Test: called directly by the `resolve_via_gateway_*` test cases.
async fn resolve_daemon_url_via_gateway_inner(
    client: &reqwest::Client,
    explicit: Option<&str>,
    console_addr: &str,
) -> Result<String, DaemonUrlError> {
    // a) Explicit override — any non-empty `Some` bypasses the gateway
    //    entirely, whether or not the value happens to equal
    //    `DEFAULT_DAEMON_URL` (#2487). Callers only pass `Some` when the
    //    operator actually supplied `--url` / `TRUSTY_MPM_URL`.
    if let Some(url) = explicit
        && !url.is_empty()
    {
        return Ok(url.to_string());
    }

    // b) Gateway probe — build the gateway base URL and probe its /health.
    //    `probe_url` appends "/health", so the actual request is:
    //    `GET http://{console}/api/mpm/health`, which the console proxy
    //    forwards to the daemon's `GET /health`.
    //
    //    IMPORTANT: use a **dedicated** short-timeout client for this probe,
    //    independent of the caller's `client`. Even now that the caller's
    //    client is bounded (`client::http_client::default_client()`, issue
    //    #2517 — previously a bare `reqwest::Client::new()` in main.rs with
    //    no timeout at all), its bound (10s) is tuned for the local daemon,
    //    not this console-gateway hop. A slow or unreachable console must
    //    NEVER stall every `tm` command for up to 10 seconds — the gateway
    //    probe must fail fast ([`GATEWAY_PROBE_TIMEOUT`]) and fall through to
    //    the direct path.
    //    The per-request `.timeout()` in `probe_url` provides a second line of
    //    defense; the client-level timeout here is the primary guarantee.
    let probe_client = reqwest::Client::builder()
        .timeout(GATEWAY_PROBE_TIMEOUT)
        .build()
        // Client::builder().timeout(...).build() is infallible without custom
        // TLS or certificate configuration — both are absent here.
        .expect("gateway probe client is infallible");

    let gateway_base = format!("http://{console_addr}{GATEWAY_PATH}");
    if probe_url(&probe_client, &gateway_base).await {
        return Ok(gateway_base);
    }

    // c) Direct fallback — console absent or gateway probe failed. Delegate to
    //    the direct-daemon resolver chain (lock file → DEFAULT_DAEMON_URL) so
    //    `tm` still works when only the daemon is running. `explicit` is
    //    always `None`/empty by this point (step (a) already returned for any
    //    real override), so `resolve_daemon_url_probing` can never produce
    //    `Err(DaemonUrlError::Unreachable)` here (#1737) — that variant is
    //    only reachable via its explicit-URL probe branch. #9556: it can
    //    produce `Err(NoSandboxDaemon)` in an isolated process with no live
    //    lock, which is passed up rather than replaced by the default.
    resolve_daemon_url_probing(client, explicit).await
}

/// Probe a URL for reachability with a short timeout.
///
/// Why: used by `resolve_daemon_url_probing` to validate stale explicit URLs
/// before committing to them. A 500 ms timeout keeps the probe imperceptible
/// to the user while still covering slow loopback responses.
/// What: sends `GET <url>/health` (trailing slash on `url` is stripped first
/// to avoid the double-slash path `//health`); returns `true` if the response
/// has any 2xx status, `false` on error (connection refused, timeout, etc.).
/// Test: `probing_resolver_errors_when_explicit_unreachable`,
/// `probing_resolver_wins_when_explicit_reachable`,
/// `probe_url_trims_trailing_slash`.
async fn probe_url(client: &reqwest::Client, url: &str) -> bool {
    use std::time::Duration;
    let base = url.trim_end_matches('/');
    let probe = client
        .get(format!("{base}/health"))
        .timeout(Duration::from_millis(500))
        .send()
        .await;
    probe.map(|r| r.status().is_success()).unwrap_or(false)
}

/// Read the daemon URL from the lock file if present and the PID is alive.
///
/// #1731: the parse, the product-magic check, and the stale-record cleanup all
/// live in [`crate::core::daemon_identity`] — this hand-rolled a second copy of
/// them, and trusted any TOML at the path with an `addr` line.
fn read_lock_file_url() -> Option<String> {
    super::daemon_identity::read_lock().map(|lock| lock.addr)
}

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

    #[test]
    fn explicit_url_wins_over_everything() {
        let result = resolve_daemon_url(Some("http://example.com:9999"));
        assert_eq!(result, "http://example.com:9999");
    }

    #[test]
    fn empty_explicit_falls_through() {
        // With no lock file and empty explicit, must return default.
        // (Lock file path may or may not exist on CI; we just assert not empty.)
        let result = resolve_daemon_url(Some(""));
        assert!(!result.is_empty());
    }

    #[test]
    #[serial_test::serial]
    fn default_returned_when_no_lock_and_no_explicit() {
        // If no lock file exists this returns DEFAULT_DAEMON_URL.
        // We can't guarantee no lock file exists, so just check it's a valid URL.
        // #9556: an isolated process refuses the default; run outside one.
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let result = resolve_daemon_url(None);
        assert!(result.starts_with("http"));
    }

    #[test]
    fn default_url_matches_addr() {
        // Why (issue #1268): the client default URL and the daemon bind default
        // must agree. `DEFAULT_DAEMON_URL` is `http://` + `DEFAULT_DAEMON_ADDR`.
        assert_eq!(
            DEFAULT_DAEMON_URL,
            format!("http://{DEFAULT_DAEMON_ADDR}"),
            "client default URL and daemon bind addr drifted (issue #1268)"
        );
    }

    #[test]
    fn default_addr_parses() {
        // The shared bind constant must parse into a SocketAddr so the daemon's
        // clap `--addr` default can be derived from it.
        let addr = default_daemon_addr();
        assert_eq!(addr.to_string(), DEFAULT_DAEMON_ADDR);
        assert_eq!(addr.port(), 7880);
    }

    #[test]
    fn explicit_value_equal_to_default_wins_immediately() {
        // Why (issue #2487): prior to the fix, `resolve_daemon_url` compared the
        // explicit value against `DEFAULT_DAEMON_URL` by string equality and, on a
        // match, treated it as "not a real override" — falling through to the lock
        // file. That heuristic could never distinguish "clap injected the default
        // because nothing was passed" from "the operator explicitly set
        // TRUSTY_MPM_URL to the literal default value", so a deliberate
        // `TRUSTY_MPM_URL=http://127.0.0.1:7880` silently lost to a running lock
        // file (or, one layer up in `resolve_daemon_url_via_gateway`, to the
        // trusty-console gateway probe — the exact #2487 symptom). Callers now only
        // ever pass `Some` when the flag/env var was truly supplied (every
        // CLI-facing `url` field is `Option<String>` with no `default_value`), so
        // this function no longer needs — or performs — that comparison: `Some`
        // always wins, deterministically, with no dependency on lock-file state.
        let result = resolve_daemon_url(Some(DEFAULT_DAEMON_URL));
        assert_eq!(
            result, DEFAULT_DAEMON_URL,
            "an explicit value equal to the default must win outright, not fall through to the lock file"
        );
    }

    #[test]
    fn lock_file_path_is_under_framework_root() {
        // Why: the lock file must share the `~/.trusty-mpm` root with every
        // other framework artifact. A path under `~/.config` (the previous
        // behaviour) meant the daemon and its clients could disagree on the
        // location and the TUI would report "daemon unreachable".
        let path = lock_file_path();
        assert!(
            path.ends_with(format!("{FRAMEWORK_DIR_NAME}/daemon.lock")),
            "lock file path {path:?} is not under the {FRAMEWORK_DIR_NAME} root"
        );
        // The parent directory is the framework root itself.
        assert_eq!(
            path.parent().and_then(|p| p.file_name()),
            Some(std::ffi::OsStr::new(FRAMEWORK_DIR_NAME))
        );
    }

    // ── resolve_daemon_url_probing tests (#1731, #1737) ──────────────────────

    /// Stale EXPLICIT URL (unreachable) errors — no fallback (#1737).
    ///
    /// Why: `TRUSTY_MPM_URL=http://127.0.0.1:1` (or `--url http://127.0.0.1:1`)
    /// is a deliberate, operator-supplied override — port 1 is reserved and
    /// always refused. Prior to #1737 the probing resolver silently fell back
    /// to the lock file or `DEFAULT_DAEMON_URL` on probe failure, which could
    /// retarget a destructive command (e.g. `tm session prune-idle`) at a
    /// DIFFERENT daemon than the one the operator named. It must now error
    /// instead, naming the URL that failed.
    /// What: calls `resolve_daemon_url_probing` with the reserved port; asserts
    /// `Err(DaemonUrlError::Unreachable { url })` with the stale URL echoed
    /// back verbatim.
    /// Test: this test.
    #[tokio::test]
    async fn probing_resolver_errors_when_explicit_unreachable() {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(200))
            .build()
            .expect("build client");
        let stale = "http://127.0.0.1:1"; // port 1 is reserved, always refused
        let result = resolve_daemon_url_probing(&client, Some(stale)).await;
        assert_eq!(
            result,
            Err(DaemonUrlError::Unreachable {
                url: stale.to_string()
            }),
            "explicit unreachable URL must error, not fall back to a different daemon"
        );
    }

    /// Reachable explicit URL wins immediately, lock file is not consulted (#1731).
    ///
    /// Why: a deliberately non-default URL (`--url http://…:9999`) must still
    /// win when the daemon is reachable at that address. The probe must not
    /// cause a regression for the `tm --url <custom>` workflow.
    /// What: binds a minimal TCP listener that responds with HTTP 200 on any
    /// connection, calls `resolve_daemon_url_probing` with that address, and
    /// asserts the result is `Ok` with the listener's address.
    /// Test: this test.
    #[tokio::test]
    async fn probing_resolver_wins_when_explicit_reachable() {
        use tokio::io::AsyncWriteExt as _;

        // Bind to an ephemeral port and respond HTTP 200 to the first connection.
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = listener.local_addr().expect("local_addr");
        let url = format!("http://{addr}");

        tokio::spawn(async move {
            if let Ok((mut sock, _)) = listener.accept().await {
                // A minimal HTTP/1.1 200 response is enough for reqwest to
                // call `r.status().is_success()`.
                let _ = sock
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
                    .await;
            }
        });

        let client = reqwest::Client::new();
        let result = resolve_daemon_url_probing(&client, Some(&url)).await;
        assert_eq!(
            result,
            Ok(url),
            "reachable explicit URL must win over lock file / default"
        );
    }

    /// Implicit resolution (no explicit override) still returns `Ok` and
    /// preserves the lock-file → default fallback chain untouched by #1737.
    ///
    /// Why: #1737 must NOT change behaviour when the operator did not supply
    /// `--url`/`TRUSTY_MPM_URL` at all — only the explicit-and-unreachable
    /// case gains the new error path.
    /// What: calls `resolve_daemon_url_probing(&client, None)`; asserts the
    /// result is `Ok` and a valid HTTP URL (lock file or `DEFAULT_DAEMON_URL`
    /// depending on local state, which the test does not control).
    /// Test: this test.
    #[tokio::test]
    #[serial_test::serial]
    async fn probing_resolver_implicit_none_falls_back_ok() {
        // #9556: an isolated process refuses the default; run outside one.
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let client = reqwest::Client::new();
        let result = resolve_daemon_url_probing(&client, None).await;
        let url = result.expect("implicit resolution must never error");
        assert!(url.starts_with("http"), "expected HTTP URL, got: {url}");
    }

    /// The exit code documented for [`DaemonUrlError::Unreachable`] matches the
    /// established `EX_TEMPFAIL`-adjacent convention (75) already used by
    /// `commands::prune::EXIT_SM_UNAVAILABLE` (#1737).
    #[test]
    fn exit_code_matches_sm_unavailable_convention() {
        assert_eq!(EXIT_DAEMON_URL_UNREACHABLE, 75);
    }

    // ── resolve_daemon_url_for_cli tests (#1737) ──────────────────────────────

    /// An explicit, unreachable `--url` errors through the CLI-facing resolver
    /// too — the gateway/lock-file/default chain must never be consulted.
    ///
    /// Why: this is the exact end-to-end contract `main.rs` and
    /// `commands::projects::launch_bare_tui` rely on.
    /// What: calls `resolve_daemon_url_for_cli` with the reserved port 1;
    /// asserts `Err(DaemonUrlError::Unreachable { url })`.
    /// Test: this test.
    #[tokio::test]
    async fn resolve_for_cli_explicit_unreachable_errors() {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(200))
            .build()
            .expect("build client");
        let stale = "http://127.0.0.1:1";
        let result = resolve_daemon_url_for_cli(&client, Some(stale)).await;
        assert_eq!(
            result,
            Err(DaemonUrlError::Unreachable {
                url: stale.to_string()
            })
        );
    }

    /// No explicit URL still resolves successfully through the full implicit
    /// fallback chain (gateway → lock file → default), unchanged by #1737.
    ///
    /// What: calls `resolve_daemon_url_for_cli(&client, None)`; asserts `Ok`
    /// with a valid HTTP URL.
    /// Test: this test.
    #[tokio::test]
    #[serial_test::serial]
    async fn resolve_for_cli_implicit_falls_back() {
        // #9556: an isolated process refuses the default; run outside one.
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let client = reqwest::Client::new();
        let result = resolve_daemon_url_for_cli(&client, None).await;
        let url = result.expect("implicit resolution must never error");
        assert!(url.starts_with("http"), "expected HTTP URL, got: {url}");
    }

    // ── resolve_daemon_url_via_gateway tests (#1849 Phase 2) ─────────────────

    /// Explicit user-supplied URL bypasses the gateway entirely.
    ///
    /// Why: `--url http://…:9999` must still win when the operator deliberately
    /// overrides the address. The gateway resolver must not intercept it.
    /// What: calls `resolve_daemon_url_via_gateway_inner` with a non-default,
    /// non-empty explicit URL and a dummy console addr; asserts the explicit
    /// URL is returned unchanged.
    /// Test: this test.
    #[tokio::test]
    async fn resolve_via_gateway_explicit_override_wins() {
        let client = reqwest::Client::new();
        let explicit = "http://127.0.0.1:9999";
        let result =
            resolve_daemon_url_via_gateway_inner(&client, Some(explicit), "127.0.0.1:1").await;
        assert_eq!(
            result.as_deref(),
            Ok(explicit),
            "explicit override must win over gateway probe"
        );
    }

    /// Regression test for issue #2487: an explicit `TRUSTY_MPM_URL` that
    /// happens to equal [`DEFAULT_DAEMON_URL`] must still bypass the gateway,
    /// even when a live console is reachable and would otherwise win.
    ///
    /// Why: this is the exact production symptom — `TRUSTY_MPM_URL=http://
    /// 127.0.0.1:7880` (7880 IS `DEFAULT_DAEMON_URL`) got silently rerouted
    /// through the trusty-console proxy on port 7788 because the pre-fix
    /// resolver's `url != DEFAULT_DAEMON_URL` check made it indistinguishable
    /// from "nothing was passed". This test binds a listener that WOULD win
    /// the gateway probe (responds 200 to `/api/mpm/health`) to prove the
    /// explicit value still wins outright and the listener is never even
    /// contacted.
    /// What: calls `resolve_daemon_url_via_gateway_inner` with
    /// `Some(DEFAULT_DAEMON_URL)` and a console address that answers every
    /// probe with HTTP 200; asserts the result is `DEFAULT_DAEMON_URL`
    /// verbatim (not the gateway base URL) and that the listener saw zero
    /// connections.
    /// Test: this test.
    #[tokio::test]
    async fn resolve_via_gateway_explicit_override_equal_to_default_still_wins() {
        use tokio::io::AsyncWriteExt as _;

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = listener.local_addr().expect("local_addr");
        let hit_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let hit_count_clone = hit_count.clone();
        tokio::spawn(async move {
            // Accept-and-200 loop: if the gateway were (incorrectly) probed,
            // this would answer successfully and the bug would still be masked.
            while let Ok((mut sock, _)) = listener.accept().await {
                hit_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                let _ = sock
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
                    .await;
            }
        });

        let client = reqwest::Client::new();
        let console_addr = addr.to_string();
        let result =
            resolve_daemon_url_via_gateway_inner(&client, Some(DEFAULT_DAEMON_URL), &console_addr)
                .await;

        assert_eq!(
            result.as_deref(),
            Ok(DEFAULT_DAEMON_URL),
            "TRUSTY_MPM_URL equal to the default must win outright, never the gateway URL"
        );
        // Give any (incorrect) in-flight probe a moment to land before asserting
        // the listener was never contacted.
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        assert_eq!(
            hit_count.load(std::sync::atomic::Ordering::SeqCst),
            0,
            "the gateway/console must never be probed when an explicit override is present"
        );
    }

    /// Gateway is used when the console probe returns 200, and the probe
    /// specifically targets `/api/mpm/health` — not the bare base URL.
    ///
    /// Why: the primary goal of gateway resolution — when the console is
    /// running and the daemon is reachable through it, all `tm` traffic should
    /// route via `http://{console}/api/mpm`. Confirming the probe path prevents
    /// a regression where a bare GET /api/mpm (empty suffix) gets routed
    /// differently from GET /api/mpm/health by the proxy, causing spurious
    /// fallback to direct even when the gateway is healthy.
    /// What: binds an ephemeral TCP listener that reads the request line and
    /// asserts it is `GET /api/mpm/health …` before responding HTTP 200; uses
    /// its address as the injected `console_addr`; calls
    /// `resolve_daemon_url_via_gateway_inner` with no explicit override; asserts
    /// the returned URL is the gateway base URL.
    /// Test: this test.
    #[tokio::test]
    async fn resolve_via_gateway_uses_gateway_when_probe_succeeds() {
        use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = listener.local_addr().expect("local_addr");

        // Capture the request line to confirm the probe targets /api/mpm/health.
        let (path_tx, path_rx) = tokio::sync::oneshot::channel::<String>();
        tokio::spawn(async move {
            if let Ok((sock, _)) = listener.accept().await {
                let (reader, mut writer) = sock.into_split();
                let mut lines = BufReader::new(reader).lines();
                let req_line = lines.next_line().await.unwrap().unwrap_or_default();
                let _ = path_tx.send(req_line);
                // Respond 200 only after capturing the line so the reqwest
                // future sees the status and probe_url returns true.
                let _ = writer
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
                    .await;
            }
        });

        let client = reqwest::Client::new();
        let console_addr = addr.to_string();
        let result = resolve_daemon_url_via_gateway_inner(&client, None, &console_addr).await;

        let expected_gateway = format!("http://{console_addr}{GATEWAY_PATH}");
        assert_eq!(
            result,
            Ok(expected_gateway),
            "gateway URL must be returned when console probe succeeds"
        );

        // Verify the probe hit /api/mpm/health, not the bare /api/mpm base.
        let req_line = path_rx.await.expect("request line must be captured");
        assert!(
            req_line.starts_with("GET /api/mpm/health "),
            "gateway probe must target /api/mpm/health; got: {req_line:?}"
        );
    }

    /// Direct fallback is used when the gateway probe fails.
    ///
    /// Why: when the console is not running (port 1 is always refused) the
    /// resolver must fall through to the direct daemon chain, not return the
    /// unreachable gateway URL.
    /// What: calls `resolve_daemon_url_via_gateway_inner` with port 1 as the
    /// console addr (always refused) and no explicit override; asserts the
    /// result is a valid HTTP URL and NOT the failed gateway URL.
    /// Test: this test.
    #[tokio::test]
    #[serial_test::serial]
    async fn resolve_via_gateway_falls_back_to_direct_on_probe_failure() {
        // #9556: an isolated process refuses the default; run outside one.
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(200))
            .build()
            .expect("build client");
        // Port 1 is reserved and never listening — probe must fail immediately.
        let dead_console = "127.0.0.1:1";
        let result = resolve_daemon_url_via_gateway_inner(&client, None, dead_console)
            .await
            .expect("outside isolation the direct fallback never errors");

        let gateway_url = format!("http://{dead_console}{GATEWAY_PATH}");
        assert!(
            result.starts_with("http"),
            "fallback must still return an HTTP URL, got: {result}"
        );
        assert_ne!(
            result, gateway_url,
            "must not return the unreachable gateway URL on probe failure"
        );
    }

    /// Trailing slash on the base URL must not produce a double-slash path.
    ///
    /// Why: `probe_url("http://…:PORT/")` previously produced
    /// `GET http://…:PORT//health` — the double slash could cause 404s on
    /// strict HTTP servers, making a live daemon appear unreachable. The fix
    /// trims the trailing slash before appending `/health`.
    /// What: binds a TCP listener that echoes the request line in its 200
    /// response body; asserts the captured path is `/health` not `//health`.
    /// Test: this test.
    #[tokio::test]
    async fn probe_url_trims_trailing_slash() {
        use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = listener.local_addr().expect("local_addr");

        // Capture the request line so we can assert on the path.
        let (path_tx, path_rx) = tokio::sync::oneshot::channel::<String>();
        tokio::spawn(async move {
            if let Ok((sock, _)) = listener.accept().await {
                let (reader, mut writer) = sock.into_split();
                let mut lines = BufReader::new(reader).lines();
                // First line is the request line, e.g. "GET /health HTTP/1.1"
                let req_line = lines.next_line().await.unwrap().unwrap_or_default();
                let _ = path_tx.send(req_line);
                let _ = writer
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
                    .await;
            }
        });

        let client = reqwest::Client::new();
        // URL with trailing slash — must probe `/health`, not `//health`.
        let url_with_slash = format!("http://{addr}/");
        probe_url(&client, &url_with_slash).await;

        let req_line = path_rx.await.expect("request line captured");
        // The path segment must be exactly "/health" with no double slash.
        assert!(
            req_line.starts_with("GET /health "),
            "probe must request /health (no double slash); got: {req_line:?}"
        );
    }

    // ── explicit_url_from_env tests (#2487) ───────────────────────────────

    /// Serialises the `explicit_url_from_env` tests' `TRUSTY_MPM_URL`
    /// mutation against every other test in the crate that reads/writes
    /// process env vars — `std::env::set_var`/`remove_var` are not
    /// thread-safe across concurrently running tests (the known env-race
    /// flake class in this workspace). Delegates to the crate-wide
    /// [`crate::core::trusty_tools_config::env_test_lock`] so these tests are
    /// serialised against env tests in OTHER modules too, not just each
    /// other.
    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        crate::core::trusty_tools_config::env_test_lock()
    }

    #[test]
    fn explicit_url_from_env_reads_set_var() {
        let _g = env_lock();
        // SAFETY: guarded by env_lock; restored to the prior value below.
        let prior = std::env::var("TRUSTY_MPM_URL").ok();
        unsafe { std::env::set_var("TRUSTY_MPM_URL", "http://127.0.0.1:7880") };
        let result = explicit_url_from_env();
        match prior {
            Some(v) => unsafe { std::env::set_var("TRUSTY_MPM_URL", v) },
            None => unsafe { std::env::remove_var("TRUSTY_MPM_URL") },
        }
        assert_eq!(
            result.as_deref(),
            Some("http://127.0.0.1:7880"),
            "must read TRUSTY_MPM_URL verbatim, even when it equals the default"
        );
    }

    #[test]
    fn explicit_url_from_env_treats_empty_as_unset() {
        let _g = env_lock();
        let prior = std::env::var("TRUSTY_MPM_URL").ok();
        // SAFETY: guarded by env_lock; restored to the prior value below.
        unsafe { std::env::set_var("TRUSTY_MPM_URL", "   ") };
        let result = explicit_url_from_env();
        match prior {
            Some(v) => unsafe { std::env::set_var("TRUSTY_MPM_URL", v) },
            None => unsafe { std::env::remove_var("TRUSTY_MPM_URL") },
        }
        assert_eq!(
            result, None,
            "a whitespace-only TRUSTY_MPM_URL must resolve to None, same as unset"
        );
    }

    #[test]
    fn explicit_url_from_env_unset_is_none() {
        let _g = env_lock();
        let prior = std::env::var("TRUSTY_MPM_URL").ok();
        // SAFETY: guarded by env_lock; restored to the prior value below.
        unsafe { std::env::remove_var("TRUSTY_MPM_URL") };
        let result = explicit_url_from_env();
        if let Some(v) = prior {
            unsafe { std::env::set_var("TRUSTY_MPM_URL", v) };
        }
        assert_eq!(result, None, "an unset TRUSTY_MPM_URL must resolve to None");
    }

    /// Why: a console configured on a non-default port is the whole reason this
    /// resolver exists — a fallback that ignored the recorded address would
    /// still pass a default-port-only test.
    /// Test: itself.
    #[test]
    fn console_addr_from_prefers_the_recorded_addr() {
        assert_eq!(
            console_addr_from(Some("127.0.0.1:9911".to_string())),
            "127.0.0.1:9911"
        );
        // Surrounding whitespace in the discovery file must not leak into a URL.
        assert_eq!(
            console_addr_from(Some("  127.0.0.1:9911\n".to_string())),
            "127.0.0.1:9911"
        );
    }

    /// Why: the console is often not running, and no caller may fail or block
    /// because of that — every absent/blank case degrades to the default.
    /// Test: itself.
    #[test]
    fn console_addr_from_falls_back_when_absent_or_blank() {
        assert_eq!(console_addr_from(None), DEFAULT_CONSOLE_ADDR);
        assert_eq!(console_addr_from(Some(String::new())), DEFAULT_CONSOLE_ADDR);
        assert_eq!(
            console_addr_from(Some("   \n".to_string())),
            DEFAULT_CONSOLE_ADDR
        );
    }

    /// Why: the statusline link target is this string, so the `http://` prefix
    /// and the selected port must both be pinned.
    /// Test: itself.
    #[test]
    fn console_base_url_wraps_the_resolved_addr() {
        let url = console_base_url();
        assert!(
            url.starts_with("http://"),
            "console base URL must be an http:// URL: {url}"
        );
        // With no discovery file this is the default; with one it is whatever
        // the console recorded. Either way the port must survive into the URL.
        assert_eq!(url, format!("http://{}", console_addr()));
    }

    // ── #9556: the gateway probe under a sandbox / data-dir override ───────

    use crate::secret_source::test_env::EnvVarGuard;
    use trusty_common::DATA_DIR_OVERRIDE_ENV;
    use trusty_common::credentials::SANDBOX_ENV_VAR;

    /// Answer one connection on `listener` with `200 OK`, so a gateway probe
    /// against it succeeds.
    fn serve_one_ok(listener: tokio::net::TcpListener) {
        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
        tokio::spawn(async move {
            if let Ok((mut sock, _)) = listener.accept().await {
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf).await;
                let _ = sock
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
                    .await;
            }
        });
    }

    /// Whether anything connected to `listener` during the call under test.
    /// The kernel queues a finished handshake even when nobody called
    /// `accept`, so a probe that ran and gave up is still counted.
    async fn was_contacted(listener: &tokio::net::TcpListener) -> bool {
        tokio::time::timeout(std::time::Duration::from_millis(300), listener.accept())
            .await
            .is_ok()
    }

    /// Why (#9556): a sandbox with no recorded console must not fall back to
    /// the default console, which is the live one. The listener stands in for
    /// that default, so the test never dials the real 127.0.0.1:7788.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn gateway_probe_skipped_under_data_dir_override() {
        let data = crate::test_support::hermetic_temp_dir();
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        let recorded = trusty_common::read_daemon_addr("trusty-console")
            .ok()
            .flatten();
        assert_eq!(recorded, None, "the override dir records no console");

        let default_console = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = default_console
            .local_addr()
            .expect("local_addr")
            .to_string();
        let result =
            resolve_daemon_url_via_gateway_from(&reqwest::Client::new(), None, recorded, &addr)
                .await;

        assert_ne!(result, Ok(format!("http://{addr}{GATEWAY_PATH}")));
        assert!(
            !was_contacted(&default_console).await,
            "under {DATA_DIR_OVERRIDE_ENV} the default console must get zero connections"
        );
    }

    /// Why (#9556): `TRUSTY_SANDBOX=1` alone isolates the client the same way.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn gateway_probe_skipped_under_sandbox_flag() {
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let _sandbox = EnvVarGuard::set(SANDBOX_ENV_VAR, "1");
        let default_console = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = default_console
            .local_addr()
            .expect("local_addr")
            .to_string();
        let _ =
            resolve_daemon_url_via_gateway_from(&reqwest::Client::new(), None, None, &addr).await;

        assert!(
            !was_contacted(&default_console).await,
            "under {SANDBOX_ENV_VAR}=1 the default console must get zero connections"
        );
    }

    /// Why (#9556): a console the sandbox itself recorded is still a gateway
    /// the client may use.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn gateway_probe_uses_recorded_addr_under_data_dir_override() {
        let data = crate::test_support::hermetic_temp_dir();
        let _sandbox = EnvVarGuard::set(SANDBOX_ENV_VAR, "1");
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        let recorded = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = recorded.local_addr().expect("local_addr").to_string();
        serve_one_ok(recorded);

        let result = resolve_daemon_url_via_gateway_from(
            &reqwest::Client::new(),
            None,
            Some(addr.clone()),
            // Never dialled: the recorded address wins. Port 1 refuses anyway.
            "127.0.0.1:1",
        )
        .await;
        assert_eq!(result, Ok(format!("http://{addr}{GATEWAY_PATH}")));
    }

    /// Why (#9556): outside a sandbox the default console is still probed, so
    /// the fix must not skip the gateway everywhere.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn gateway_probe_uses_default_console_outside_isolation() {
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let default_console = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind listener");
        let addr = default_console
            .local_addr()
            .expect("local_addr")
            .to_string();
        serve_one_ok(default_console);

        let result =
            resolve_daemon_url_via_gateway_from(&reqwest::Client::new(), None, None, &addr).await;
        assert_eq!(result, Ok(format!("http://{addr}{GATEWAY_PATH}")));
    }

    // ── #9556: no host default daemon under isolation ──────────────────────

    /// Why (#9556): a sandbox whose own daemon died (its dead-pid lock already
    /// deleted) fell through to `DEFAULT_DAEMON_URL`, the live fleet. With no
    /// explicit URL, no recorded console and no live lock, every resolver must
    /// refuse instead. `HOME` is a temp dir, so no lock exists; the override
    /// dir records no console, so nothing is probed.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn resolve_refuses_default_daemon_under_isolation() {
        let home = crate::test_support::hermetic_temp_dir();
        let data = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        let client = reqwest::Client::new();

        assert_ne!(resolve_daemon_url(None), DEFAULT_DAEMON_URL);
        assert_ne!(
            resolve_daemon_url_via_gateway(&client, None).await,
            DEFAULT_DAEMON_URL
        );
        for result in [
            resolve_daemon_url_probing(&client, None).await,
            resolve_daemon_url_for_cli(&client, None).await,
        ] {
            let err = result.expect_err("an isolated resolver must refuse, not fall back");
            assert!(
                err.to_string()
                    .contains("no daemon reachable for this sandbox"),
                "the refusal must say why: {err}"
            );
        }
    }

    /// Why (#9556): outside a sandbox the default daemon is still the answer
    /// when no lock exists.
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn resolve_keeps_default_daemon_outside_isolation() {
        let home = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        let client = reqwest::Client::new();

        assert_eq!(resolve_daemon_url(None), DEFAULT_DAEMON_URL);
        assert_eq!(
            resolve_daemon_url_probing(&client, None).await,
            Ok(DEFAULT_DAEMON_URL.to_string())
        );
    }

    /// Why (#9556 critic): an isolated process that inherited a home holding
    /// the host daemon's lock read the live 127.0.0.1:7880 from it. The temp
    /// `HOME` stands in for that host home; its lock names the default and a
    /// live pid (this process).
    /// Test: itself.
    #[tokio::test]
    #[serial_test::serial]
    async fn isolated_resolver_ignores_a_host_lock_naming_the_default() {
        let home = crate::test_support::hermetic_temp_dir();
        let data = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::set(SANDBOX_ENV_VAR, "1");
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        crate::core::daemon_identity::write_lock_at(&lock_file_path(), DEFAULT_DAEMON_URL, "");

        assert_ne!(resolve_daemon_url(None), DEFAULT_DAEMON_URL);
        let err = resolve_daemon_url_probing(&reqwest::Client::new(), None)
            .await
            .expect_err("a host lock is not this sandbox's daemon");
        assert!(
            err.to_string()
                .contains("no daemon reachable for this sandbox"),
            "{err}"
        );
    }

    /// Why (#9556 delta critic): the banner and statusline read the lock
    /// through [`client_lock_url`]; with `TRUSTY_DATA_DIR_OVERRIDE` set and a
    /// host lock in `HOME`, they showed the live daemon online. The temp
    /// `HOME` stands in for the inherited home; its lock names the default and
    /// a live pid (this process).
    /// Test: itself.
    #[test]
    #[serial_test::serial]
    fn client_lock_url_ignores_a_host_lock_under_isolation() {
        let home = crate::test_support::hermetic_temp_dir();
        let data = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        crate::core::daemon_identity::write_lock_at(&lock_file_path(), DEFAULT_DAEMON_URL, "");

        assert_eq!(client_lock_url(), None, "a host lock is not this sandbox's");
    }

    /// Why (#9556 delta critic): outside isolation the filtered reader is
    /// exactly `read_lock`'s address, the default included.
    /// Test: itself.
    #[test]
    #[serial_test::serial]
    fn client_lock_url_is_read_lock_outside_isolation() {
        let home = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        crate::core::daemon_identity::write_lock_at(&lock_file_path(), DEFAULT_DAEMON_URL, "");

        assert_eq!(client_lock_url().as_deref(), Some(DEFAULT_DAEMON_URL));
        assert_eq!(
            client_lock_url(),
            crate::core::daemon_identity::read_lock().map(|lock| lock.addr)
        );
    }

    /// Why (#9556 critic): a lock under the sandbox's own `HOME` is how
    /// `scripts/sandbox_daemon.sh` hands a client its daemon; it still wins.
    /// Test: itself.
    #[test]
    #[serial_test::serial]
    fn isolated_resolver_keeps_a_sandbox_home_lock() {
        let home = crate::test_support::hermetic_temp_dir();
        let data = crate::test_support::hermetic_temp_dir();
        let _home = EnvVarGuard::set("HOME", home.path());
        let _sandbox = EnvVarGuard::set(SANDBOX_ENV_VAR, "1");
        let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
        let sandbox_daemon = "http://127.0.0.1:17881";
        crate::core::daemon_identity::write_lock_at(&lock_file_path(), sandbox_daemon, "");

        assert_eq!(resolve_daemon_url(None), sandbox_daemon);
    }

    /// Why (#9556 critic): the host-lock rule must reject both the default
    /// address and a lock under the account home, and nothing else.
    /// Test: itself.
    #[test]
    fn host_lock_is_judged_by_address_and_home() {
        let sandbox = crate::test_support::hermetic_temp_dir();
        let account = crate::test_support::hermetic_temp_dir();
        let (sandbox, account) = (Some(sandbox.path()), Some(account.path()));
        let other = "http://127.0.0.1:17881";
        assert!(!lock_is_hosts(other, sandbox, account));
        assert!(lock_is_hosts(other, account, account));
        assert!(lock_is_hosts(DEFAULT_DAEMON_URL, sandbox, account));
        assert!(lock_is_hosts("http://127.0.0.1:7880/", sandbox, account));
        assert!(lock_is_hosts(other, None, account));
        assert!(lock_is_hosts(other, sandbox, None));
    }

    /// Why (#9556 critic): with isolation on and no recorded console, the
    /// banner's console probe target is "none", never the host's 7788.
    /// Test: itself.
    #[test]
    fn console_probe_target_is_none_under_isolation() {
        assert_eq!(console_addr_to_probe_from(None, true), None);
        assert_eq!(
            console_addr_to_probe_from(None, false).as_deref(),
            Some(DEFAULT_CONSOLE_ADDR)
        );
        assert_eq!(
            console_addr_to_probe_from(Some(" 127.0.0.1:9911\n".into()), true).as_deref(),
            Some("127.0.0.1:9911")
        );
    }

    /// Why (#9556): `tm start` and the bare-`tm` autostart must fail closed in
    /// a sandbox rather than spawn a daemon on the host default, and still
    /// spawn outside one.
    /// Test: itself.
    #[test]
    #[serial_test::serial]
    fn daemon_spawn_refused_only_under_isolation() {
        let data = crate::test_support::hermetic_temp_dir();
        {
            let _sandbox = EnvVarGuard::set(SANDBOX_ENV_VAR, "1");
            let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
            assert_eq!(
                refuse_daemon_spawn_when_isolated(),
                Err(DaemonUrlError::NoSandboxDaemon)
            );
        }
        {
            let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
            let _data = EnvVarGuard::set(DATA_DIR_OVERRIDE_ENV, data.path());
            assert_eq!(
                refuse_daemon_spawn_when_isolated(),
                Err(DaemonUrlError::NoSandboxDaemon)
            );
        }
        let _sandbox = EnvVarGuard::unset(SANDBOX_ENV_VAR);
        let _data = EnvVarGuard::unset(DATA_DIR_OVERRIDE_ENV);
        assert_eq!(refuse_daemon_spawn_when_isolated(), Ok(()));
    }

    /// Why (#9556): bare `tm` turns the sentinel back into the refusal instead
    /// of reading a refused request as a slow daemon.
    /// Test: itself.
    #[test]
    fn isolated_no_daemon_url_maps_back_to_the_refusal() {
        assert_eq!(
            refuse_isolated_no_daemon(ISOLATED_NO_DAEMON_URL),
            Err(DaemonUrlError::NoSandboxDaemon)
        );
        assert_eq!(refuse_isolated_no_daemon(DEFAULT_DAEMON_URL), Ok(()));
    }

    /// Why (#9556): a fail-open caller handed the sentinel reaches nothing.
    /// Test: itself.
    #[tokio::test]
    async fn isolated_no_daemon_url_is_refused_before_any_io() {
        let err = reqwest::Client::new()
            .get(format!("{ISOLATED_NO_DAEMON_URL}/health"))
            .send()
            .await
            .expect_err("a non-HTTP scheme is never sent");
        assert!(
            err.is_builder(),
            "refused while building, not on the wire: {err}"
        );
        assert!(err.to_string().contains(ISOLATED_NO_DAEMON_URL), "{err}");
    }
}