actl-browser 0.2.2

actl CDP channel: minimal synchronous Chrome DevTools Protocol client for actl-owned browser 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
//! 浏览器命令执行体:launch/navigate/read/click/close。
//!
//! 语义要点(docs/72):
//! - launch:绝对 user-data-dir + DevToolsActivePort 出现 = 启动成功判定;失败 fail-fast
//!   并回收自启进程(§11 不变量:自启实例必须总能经 CDP 优雅关闭);
//! - 写操作(click)先过停止代次核验,再重核绑定 target 存活,再定位唯一匹配,派发后读回;
//! - cookie 域不在实现面内(构造性不可达,lib.rs 范围铁律)。

use crate::SelectorKind;
use crate::cdp::{self, CdpError, Connection};
use crate::scripts::{locate_all_script, locate_deep_script, locate_script, popup_scan_script};
use crate::session::{self, BrowserSession, Mode, Paths};
use actl_core::state::unix_ms;
use actl_core::{CtlError, ErrorCode};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

const LAUNCH_DEFAULT_TIMEOUT_MS: u64 = 15_000;
const OP_DEFAULT_TIMEOUT_MS: u64 = 10_000;
const TEXT_LIMIT: usize = 65_536;

pub struct LaunchSpec {
    pub browser_executable: String,
    pub url: Option<String>,
    pub profile: Option<String>,
    /// 出站代理(HOST:PORT,无 scheme);显式本地代理路径,非任意参数注入
    pub proxy: Option<String>,
    pub headless: bool,
    /// 显式禁用扩展(持久 profile 用;ephemeral 恒为禁用——隔离目录本无插件,
    /// 显式化是为确定性与防指纹方差。持久 profile 默认允许扩展:猫抓/沉浸式
    /// 翻译类扩展驱动流程正是自动化 profile 的合法资产,docs/16 §5.5)
    pub disable_extensions: bool,
    pub timeout_ms: Option<u64>,
}

/// 显示层动作卡声明(P2 协同):CDP 写/生命周期命令在信号流可见。与 UIA 轨的
/// display_gate 不同——不经显示租约门(展示是透明性而非权限门,docs/72 §13 治理发现),
/// InputEffects 全空(CDP 写无物理键鼠/焦点/剪贴板效应,如实申报);target 只带会话
/// 标记,不含选择器/URL/文本(动作日志内容省略纪律)。
fn declare_action(kind: &str, session_tag: &str) -> actl_core::signal_session::ActionGuard {
    actl_core::signal_session::ActionGuard::begin(
        kind,
        actl_core::activity::InputEffects::default(),
        actl_core::activity::ActionTarget {
            window: Some(session_tag.to_string()),
            ..Default::default()
        },
    )
}

fn protocol(message: impl Into<String>) -> CtlError {
    CtlError::new(ErrorCode::Protocol, message)
}

/// CDP 连接层错误 → envelope 错误码(docs/72 §9 错误映射)。
fn map_cdp(err: CdpError) -> CtlError {
    match err.code {
        "CONNECT" => CtlError::with_evidence(
            ErrorCode::NotFound,
            format!(
                "browser devtools endpoint unreachable (browser exited or session expired): {}",
                err.message
            ),
            json!({ "cdp_code": err.code }),
        ),
        "TIMEOUT" => CtlError::new(ErrorCode::Timeout, err.message),
        _ => CtlError::with_evidence(
            ErrorCode::Internal,
            format!("CDP layer failure: {err}"),
            json!({ "cdp_code": err.code }),
        ),
    }
}

fn dur(timeout_ms: Option<u64>, default: u64) -> Duration {
    Duration::from_millis(timeout_ms.filter(|t| *t > 0).unwrap_or(default))
}

fn validate_url(url: &str) -> Result<(), CtlError> {
    let ok = url.len() <= 2048
        && ["http://", "https://", "about:blank", "file://"]
            .iter()
            .any(|p| url.starts_with(p));
    if ok {
        Ok(())
    } else {
        Err(protocol(format!(
            "url must start with http:// https:// file:// or be about:blank: {url}"
        )))
    }
}

fn validate_selector(kind: SelectorKind, value: &str) -> Result<(), CtlError> {
    if value.is_empty() || value.len() > 4096 {
        return Err(protocol(format!(
            "{} selector requires 1-4096 characters",
            kind.as_str()
        )));
    }
    Ok(())
}

/// 代理仅接受裸 HOST:PORT(拒绝 scheme/凭据),防参数注入面
fn validate_proxy(value: &str) -> Result<(), CtlError> {
    let Some((host, port)) = value.rsplit_once(':') else {
        return Err(protocol(format!(
            "--proxy requires HOST:PORT, got: {value}"
        )));
    };
    if host.is_empty()
        || !host
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'[' || b == b']')
    {
        return Err(protocol(format!(
            "--proxy host must be alphanumeric/dots/brackets: {value}"
        )));
    }
    let valid_port = port.parse::<u16>().is_ok_and(|p| (1..=65535).contains(&p));
    if !valid_port {
        return Err(protocol(format!("--proxy port must be 1-65535: {value}")));
    }
    Ok(())
}

/// 属性读取白名单(docs/06 §3;--property 的合法值)。text 为缺省行为。
pub(crate) const READ_PROPERTIES: &[&str] =
    &["text", "value", "href", "name", "id", "checked", "tag"];

fn validate_property(property: &str) -> Result<(), CtlError> {
    if READ_PROPERTIES.contains(&property) {
        Ok(())
    } else {
        Err(protocol(format!(
            "unknown --property {property:?}; allowed: {}",
            READ_PROPERTIES.join(", ")
        )))
    }
}

/// Runtime.evaluate;脚本约定返回 JSON 字符串(内部脚本,非用户表达式)。
fn eval_json(
    conn: &Connection,
    cdp_session: &str,
    script: &str,
    context_id: Option<u64>,
    timeout: Duration,
) -> Result<Value, CtlError> {
    let mut params = json!({ "expression": script, "returnByValue": true, "awaitPromise": true });
    if let Some(ctx) = context_id {
        params["contextId"] = json!(ctx);
    }
    let res = conn
        .call("Runtime.evaluate", params, Some(cdp_session), timeout)
        .map_err(map_cdp)?;
    if let Some(exc) = res.get("exceptionDetails") {
        let text = exc
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or("(no text)");
        return Err(CtlError::with_evidence(
            ErrorCode::Internal,
            format!("page script raised: {text}"),
            json!({ "exception": text }),
        ));
    }
    let raw = match res.pointer("/result/value").and_then(Value::as_str) {
        Some(s) => s.to_string(),
        None => return Err(CtlError::internal("page script returned no value")),
    };
    serde_json::from_str(&raw)
        .map_err(|_| CtlError::internal("page script returned non-JSON payload"))
}

fn connect_browser(session: &BrowserSession) -> Result<Connection, CtlError> {
    let url = format!(
        "ws://127.0.0.1:{}{}",
        session.devtools_port, session.browser_ws_path
    );
    Connection::connect(&url).map_err(map_cdp)
}

fn attach_target(
    conn: &Connection,
    target_id: &str,
    timeout: Duration,
) -> Result<String, CtlError> {
    let res = conn
        .call(
            "Target.attachToTarget",
            json!({ "targetId": target_id, "flatten": true }),
            None,
            timeout,
        )
        .map_err(map_cdp)?;
    res.get("sessionId")
        .and_then(Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| CtlError::internal("attachToTarget returned no sessionId"))
}

/// 绑定重核:target 必须仍存在(导航竞态防护的第一道闸,docs/72 §6)。
fn verify_target_alive(
    conn: &Connection,
    target_id: &str,
    timeout: Duration,
) -> Result<(), CtlError> {
    let res = conn.call(
        "Target.getTargetInfo",
        json!({ "targetId": target_id }),
        None,
        timeout,
    );
    match res {
        Ok(_) => Ok(()),
        Err(e) => Err(CtlError::with_evidence(
            ErrorCode::NotFound,
            format!(
                "bound target no longer exists (new tab took over or browser restarted); \
                 re-run browser-navigate or browser-launch: {e}"
            ),
            json!({ "target_id": target_id }),
        )),
    }
}

/// 跨源 iframe 定位:主 frame 的首个子 frame + 其执行上下文。
/// headless 未启用站点隔离时 iframe 无独立 target,上下文路线是唯一正路(docs/72 §10)。
fn iframe_context(
    conn: &Connection,
    cdp_session: &str,
    timeout: Duration,
) -> Result<(String, u64), CtlError> {
    let tree = conn
        .call("Page.getFrameTree", json!({}), Some(cdp_session), timeout)
        .map_err(map_cdp)?;
    let children = tree
        .pointer("/frameTree/childFrames")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let child_id = children
        .iter()
        .find_map(|f| f.pointer("/frame/id").and_then(Value::as_str))
        .ok_or_else(|| {
            CtlError::new(
                ErrorCode::NotFound,
                "page has no child iframe to address (--iframe is not applicable here)",
            )
        })?
        .to_string();
    // Runtime.enable 会重放既有上下文的 executionContextCreated 事件
    conn.call("Runtime.enable", json!({}), Some(cdp_session), timeout)
        .map_err(map_cdp)?;
    let deadline = Instant::now() + timeout;
    loop {
        for e in conn.drain_events() {
            if e.method != "Runtime.executionContextCreated" {
                continue;
            }
            let frame_ok = e
                .params
                .pointer("/context/auxData/frameId")
                .and_then(Value::as_str)
                .is_some_and(|id| id == child_id);
            if frame_ok && let Some(ctx) = e.params.pointer("/context/id").and_then(Value::as_u64) {
                return Ok((child_id.clone(), ctx));
            }
        }
        if Instant::now() >= deadline {
            return Err(CtlError::new(
                ErrorCode::NotFound,
                format!("no execution context observed for iframe {child_id} within timeout"),
            ));
        }
        std::thread::sleep(Duration::from_millis(20));
    }
}

/// 唯一匹配硬判定:0 → NOT_FOUND,>1 → AMBIGUOUS(带 matches 证据)。
fn require_unique_match(located: &Value, kind: SelectorKind, value: &str) -> Result<(), CtlError> {
    let matches = located.get("matches").and_then(Value::as_u64).unwrap_or(0);
    match matches {
        0 => Err(CtlError::with_evidence(
            ErrorCode::NotFound,
            format!("no element matches the {} selector", kind.as_str()),
            json!({ "selector_kind": kind.as_str(), "selector": value, "matches": 0 }),
        )),
        1 => Ok(()),
        n => Err(CtlError::with_evidence(
            ErrorCode::Ambiguous,
            format!(
                "{} elements match the {} selector; tighten it",
                n,
                kind.as_str()
            ),
            json!({ "selector_kind": kind.as_str(), "selector": value, "matches": n }),
        )),
    }
}

fn truncate_text(text: &str) -> (String, bool) {
    if text.len() <= TEXT_LIMIT {
        return (text.to_string(), false);
    }
    let mut cut = TEXT_LIMIT;
    while cut > 0 && !text.is_char_boundary(cut) {
        cut -= 1;
    }
    (text[..cut].to_string(), true)
}

/// 读取定位结果中的文本(带截断标记)。
fn located_text_payload(located: &Value) -> Value {
    let (text, truncated) = truncate_text(
        located
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or_default(),
    );
    let mut payload = json!({ "text": text, "truncated": truncated });
    if let Some(tag) = located.get("tag").and_then(Value::as_str) {
        payload["tag"] = Value::String(tag.to_string());
    }
    if let Some(id) = located.get("id").and_then(Value::as_str) {
        payload["id"] = Value::String(id.to_string());
    }
    payload
}

// ---------------------------------------------------------------------------
// launch
// ---------------------------------------------------------------------------

pub fn launch(paths: &Paths, spec: LaunchSpec) -> Result<Value, CtlError> {
    // app-registry 别名解析:名字(如 edge)→ 绝对路径;非注册名原样传
    let browser_executable = actl_uia::app_registry::resolve(&spec.browser_executable)
        .ok()
        .flatten()
        .unwrap_or_else(|| spec.browser_executable.clone());
    let exe = PathBuf::from(&browser_executable);
    if !exe.is_absolute() {
        return Err(protocol(format!(
            "browser executable path must be absolute (or a registered alias): {}",
            browser_executable
        )));
    }
    if !exe.is_file() {
        return Err(protocol(format!(
            "browser executable not found: {}",
            spec.browser_executable
        )));
    }
    let url = spec.url.unwrap_or_else(|| "about:blank".to_string());
    validate_url(&url)?;
    let mode = match spec.profile {
        Some(name) => {
            session::validate_profile_name(&name)?;
            Mode::Persistent { name }
        }
        None => Mode::Ephemeral,
    };
    if let Some(proxy) = &spec.proxy {
        validate_proxy(proxy)?;
    }
    let timeout = dur(spec.timeout_ms, LAUNCH_DEFAULT_TIMEOUT_MS);

    // 持久 profile 并发治理(§13.4):同 profile 已有活跃会话→复用(幂等 launch);
    // 显示层动作卡(P2):生命周期命令对用户可见
    let mut action = declare_action(
        "browser-launch",
        &match &mode {
            Mode::Persistent { name } => format!("cdp-profile:{name}"),
            Mode::Ephemeral => "cdp:ephemeral".to_string(),
        },
    );
    action.executing();
    // 记录在但端点已死→陈旧接管(注销旧记录后照常新启)。两个 CLI 进程同时竞启
    // 同一 profile 时,后到者的 Edge 会被先到者锁住退出→端口不出现→启动失败,如实报错。
    if let Mode::Persistent { name } = &mode
        && let Some(reused) = reuse_persistent_session(paths, name, timeout)?
    {
        action.delivered();
        return Ok(reused);
    }

    let session_id = actl_core::new_snapshot_id();
    let profile_dir = match &mode {
        Mode::Persistent { name } => paths.profiles_dir().join(name),
        Mode::Ephemeral => paths.profiles_dir().join("ephemeral").join(&session_id),
    };
    // §11 不变量:传给浏览器的 user-data-dir 必须是绝对路径
    let profile_dir = std::env::current_dir()
        .map(|d| d.join(&profile_dir))
        .unwrap_or(profile_dir);
    std::fs::create_dir_all(&profile_dir)
        .map_err(|e| CtlError::internal(format!("cannot create browser profile dir: {e}")))?;
    std::fs::create_dir_all(paths.sessions_dir())
        .map_err(|e| CtlError::internal(format!("cannot create browser sessions dir: {e}")))?;

    let mut edge_args: Vec<String> = vec![
        "--remote-debugging-port=0".to_string(),
        format!("--user-data-dir={}", profile_dir.display()),
        "--no-first-run".to_string(),
        "--no-default-browser-check".to_string(),
    ];
    if spec.headless {
        edge_args.insert(0, "--headless=new".to_string());
    }
    // 扩展策略:ephemeral 恒禁用;持久 profile 默认允许(扩展是合法资产),
    // 显式 --disable-extensions 才禁
    if spec.disable_extensions || matches!(mode, Mode::Ephemeral) {
        edge_args.push("--disable-extensions".to_string());
    }
    if let Some(proxy) = &spec.proxy {
        edge_args.push(format!("--proxy-server={proxy}"));
    }
    edge_args.push("about:blank".to_string());

    // GUI 程序不得继承控制台句柄(interact.rs launch 的既有结论):浏览器进程树会攥住
    // 调用方管道的写端,$()/ReadToEnd 永远等不到 EOF。实测 null 三件套对 Edge 多子
    // 进程树仍不够,派生前先清自身 stdio 的 INHERIT 标志(actl-uia::process_io,docs/72)。
    actl_uia::process_io::clear_stdio_inheritance();
    // 持久 profile 会残留上次退出的 DevToolsActivePort(陈旧端口假阳的温床),先清掉
    let _ = std::fs::remove_file(profile_dir.join("DevToolsActivePort"));
    let mut child = Command::new(&exe)
        .args(&edge_args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|e| CtlError::internal(format!("cannot launch browser: {e}")))?;
    let pid = child.id();

    let devtools = match wait_devtools_port(&profile_dir, timeout) {
        Some(v) => v,
        None => {
            // 启动失败:回收自启进程(无会话可交接,端口不可达即无法优雅关闭)
            let _ = child.kill();
            let _ = child.wait();
            if matches!(mode, Mode::Ephemeral) {
                let _ = std::fs::remove_dir_all(&profile_dir);
            }
            return Err(CtlError::with_evidence(
                ErrorCode::Internal,
                "browser did not expose a devtools endpoint within timeout; \
                 launch aborted and the owned process was reclaimed \
                 (user-data-dir must be non-default for the debug port, docs/72 §2.1)"
                    .to_string(),
                json!({ "pid": pid, "profile_dir": profile_dir.display().to_string() }),
            ));
        }
    };

    let version = cdp::http_get_json(devtools.0, "/json/version", timeout).map_err(|e| {
        reclaim_on_failure(&mut child, &mode, &profile_dir);
        map_cdp(e)
    })?;
    let browser_version = version
        .get("Browser")
        .and_then(Value::as_str)
        .unwrap_or("(unknown)")
        .to_string();
    let browser_ws_path = version
        .get("webSocketDebuggerUrl")
        .and_then(Value::as_str)
        .and_then(|u| u.split_once("/devtools"))
        .map(|(_, rest)| format!("/devtools{rest}"))
        .ok_or_else(|| {
            CtlError::internal("devtools /json/version returned no browser websocket url")
        })?;

    let conn = Connection::connect(&format!("ws://127.0.0.1:{}{}", devtools.0, browser_ws_path))
        .map_err(|e| {
            reclaim_on_failure(&mut child, &mode, &profile_dir);
            map_cdp(e)
        })?;
    let created = conn
        .call("Target.createTarget", json!({ "url": url }), None, timeout)
        .map_err(|e| {
            reclaim_on_failure(&mut child, &mode, &profile_dir);
            map_cdp(e)
        })?;
    let target_id = created
        .get("targetId")
        .and_then(Value::as_str)
        .ok_or_else(|| CtlError::internal("createTarget returned no targetId"))?
        .to_string();

    let record = BrowserSession {
        version: session::SESSION_VERSION,
        session_id: session_id.clone(),
        browser_executable: spec.browser_executable.clone(),
        profile_dir: profile_dir.display().to_string(),
        mode: mode.clone(),
        headless: spec.headless,
        pid,
        devtools_port: devtools.0,
        browser_ws_path,
        target_id: target_id.clone(),
        bound_url: url.clone(),
        created_unix: unix_ms() / 1000,
    };
    session::save(paths, &record)?;

    action.delivered();
    Ok(json!({
        "session_id": session_id,
        "target_id": target_id,
        "browser_pid": pid,
        "devtools_port": devtools.0,
        "browser_version": browser_version,
        "url": url,
        "mode": mode,
        "headless": spec.headless,
        "profile_dir": profile_dir.display().to_string(),
    }))
}

/// 同名持久 profile 的活跃会话查找:活→Some(复用载荷);死→注销陈旧记录后 None。
fn reuse_persistent_session(
    paths: &Paths,
    name: &str,
    timeout: Duration,
) -> Result<Option<Value>, CtlError> {
    let sessions = list(paths)?;
    let Some(arr) = sessions.get("sessions").and_then(Value::as_array) else {
        return Ok(None);
    };
    for entry in arr {
        // mode 序列化为 {"persistent":{"name":...}}
        let is_same = entry
            .pointer("/mode/persistent/name")
            .and_then(Value::as_str)
            .is_some_and(|n| n == name);
        if !is_same {
            continue;
        }
        let id = entry
            .get("session_id")
            .and_then(Value::as_str)
            .unwrap_or_default();
        let Ok(record) = session::load(paths, id) else {
            continue;
        };
        let alive = (|| {
            let conn = connect_browser(&record).ok()?;
            attach_target(&conn, &record.target_id, timeout).ok()?;
            verify_target_alive(&conn, &record.target_id, timeout).ok()
        })()
        .is_some();
        if alive {
            return Ok(Some(json!({
                "session_id": record.session_id,
                "target_id": record.target_id,
                "browser_pid": record.pid,
                "devtools_port": record.devtools_port,
                "url": record.bound_url,
                "mode": record.mode,
                "headless": record.headless,
                "profile_dir": record.profile_dir,
                "reused": true,
            })));
        }
        // 端点已死:陈旧接管,注销后走全新启动
        let _ = session::remove(paths, id);
    }
    Ok(None)
}

fn reclaim_on_failure(child: &mut Child, mode: &Mode, profile_dir: &Path) {
    let _ = child.kill();
    let _ = child.wait();
    if matches!(mode, Mode::Ephemeral) {
        let _ = std::fs::remove_dir_all(profile_dir);
    }
}

fn wait_devtools_port(profile_dir: &Path, timeout: Duration) -> Option<(u16, String)> {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if let Ok(text) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
            let mut lines = text.lines();
            if let Some(port) = lines.next().and_then(|l| l.trim().parse::<u16>().ok()) {
                // 文件出现 ≠ 端点就绪:陈旧文件(上次退出残留)与竞态启动都会假阳,
                // 必须以 /json/version 实答为准
                if cdp::http_get_json(port, "/json/version", Duration::from_millis(1000)).is_ok() {
                    let ws_path = lines.next().unwrap_or("").trim().to_string();
                    return Some((port, ws_path));
                }
            }
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    None
}

// ---------------------------------------------------------------------------
// navigate
// ---------------------------------------------------------------------------

pub fn navigate(
    paths: &Paths,
    session_id: &str,
    url: &str,
    timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
    validate_url(url)?;
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    let record = session::load(paths, session_id)?;
    let mut action = declare_action("browser-navigate", &format!("cdp:{session_id}"));
    let conn = connect_browser(&record)?;
    let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
    verify_target_alive(&conn, &record.target_id, timeout)?;
    action.executing();
    conn.call("Page.enable", json!({}), Some(&cdp_session), timeout)
        .map_err(map_cdp)?;
    let nav = conn
        .call(
            "Page.navigate",
            json!({ "url": url }),
            Some(&cdp_session),
            timeout,
        )
        .map_err(map_cdp)?;
    // 网络级加载失败必须显式失败:chrome-error 错误页不是导航成功
    // (2026-10-02 x.com 场景实证:DoH 绕过 TUN fake-ip 致连接重置曾表现为静默 ok)
    if let Some(net_error) = nav.get("errorText").and_then(Value::as_str)
        && !net_error.is_empty()
    {
        return Err(CtlError::with_evidence(
            ErrorCode::Internal,
            format!("page load failed at network level: {net_error}"),
            json!({ "url": url, "net_error": net_error }),
        ));
    }
    let frame_id = nav
        .get("frameId")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    // 导航事件经读线程可达是竞态判定的前提(spike 实证 10ms 级)
    let deadline = Instant::now() + timeout;
    let mut navigated = false;
    loop {
        for e in conn.drain_events() {
            if e.method == "Page.frameNavigated"
                && e.session.as_deref() == Some(cdp_session.as_str())
                && e.params["frame"]["id"].as_str() == Some(frame_id.as_str())
            {
                navigated = true;
            }
        }
        if navigated || Instant::now() >= deadline {
            break;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    if !navigated {
        return Err(CtlError::new(
            ErrorCode::Timeout,
            format!(
                "Page.frameNavigated for frame {frame_id} not observed within {}ms",
                timeout.as_millis()
            ),
        ));
    }
    let info = eval_json(
        &conn,
        &cdp_session,
        "(function(){return JSON.stringify({url:location.href,title:document.title})})()",
        None,
        timeout,
    )?;
    let mut record = record;
    record.bound_url = info
        .get("url")
        .and_then(Value::as_str)
        .unwrap_or(url)
        .to_string();
    session::save(paths, &record)?;
    action.delivered();
    Ok(json!({
        "session_id": session_id,
        "url": record.bound_url,
        "title": info.get("title").and_then(Value::as_str).unwrap_or_default(),
        "frame_id": frame_id,
    }))
}

// ---------------------------------------------------------------------------
// read
// ---------------------------------------------------------------------------

/// read 的参数束(同 LaunchSpec 惯例——clippy 参数上限)。
pub struct ReadSpec<'a> {
    pub session_id: &'a str,
    pub kind: SelectorKind,
    pub value: &'a str,
    pub property: Option<&'a str>,
    pub iframe: bool,
    pub timeout_ms: Option<u64>,
    pub all: bool,
    pub limit: Option<usize>,
    pub deep: bool,
    /// 沉降谓词(2026-10-11 spec):命中数 ≥ N 才返回——异步渲染页面
    /// (GitHub hpc 列表 CSR 等)的读段自稳,内建 500ms 轮询,超时 TIMEOUT
    /// 带实测命中数;与 --all/唯一读通用。
    pub wait_for: Option<u64>,
}

/// 沉降谓词(2026-10-11 spec,--wait-for N):命中数 < N 时 500ms 轮询
/// 重读直到达标或超时——异步渲染(CSR 列表)的读段自稳;超时 TIMEOUT 带
/// 实测命中数与期望下界(诚实证据:差多少、等多久)。轮询共用药洞超时
/// 预算(与单次读同界,不另开)。
fn wait_for_matches(
    conn: &Connection,
    cdp_session: &str,
    script: &str,
    context_id: Option<u64>,
    timeout: Duration,
    min: u64,
    first: Value,
) -> Result<Value, CtlError> {
    let matches_of = |v: &Value| v.get("matches").and_then(Value::as_u64).unwrap_or(0);
    if matches_of(&first) >= min {
        return Ok(first);
    }
    let deadline = std::time::Instant::now() + timeout;
    let mut current = first;
    loop {
        if std::time::Instant::now() >= deadline {
            return Err(CtlError::with_evidence(
                ErrorCode::Timeout,
                format!(
                    "browser-read --wait-for {min}: only {} matches within the timeout (async content may not have settled)",
                    matches_of(&current)
                ),
                json!({ "matches": matches_of(&current), "wait_for": min }),
            ));
        }
        std::thread::sleep(Duration::from_millis(500));
        current = eval_json(conn, cdp_session, script, context_id, timeout)?;
        if matches_of(&current) >= min {
            return Ok(current);
        }
    }
}

pub fn read(paths: &Paths, spec: ReadSpec<'_>) -> Result<Value, CtlError> {
    let ReadSpec {
        session_id,
        kind,
        value,
        property,
        iframe,
        timeout_ms,
        all,
        limit,
        deep,
        wait_for,
    } = spec;
    validate_selector(kind, value)?;
    let property = property.unwrap_or("text");
    validate_property(property)?;
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    let record = session::load(paths, session_id)?;
    let conn = connect_browser(&record)?;
    let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
    verify_target_alive(&conn, &record.target_id, timeout)?;
    let context_id = if iframe {
        Some(iframe_context(&conn, &cdp_session, timeout)?.1)
    } else {
        None
    };
    if all {
        // --all 枚举形态(2026-10-11 spec,docs/73 round7):零命中如实
        // 空集(不是 NOT_FOUND——枚举语义与 find 一致),limit 只裁返回
        // 面,total 保真;逐元素文本截断与唯一读同规。
        const ALL_LIMIT: usize = 50;
        const ALL_LIMIT_MAX: usize = 500;
        let limit = limit.unwrap_or(ALL_LIMIT);
        if !(1..=ALL_LIMIT_MAX).contains(&limit) {
            return Err(protocol(format!(
                "browser-read --limit must be 1..={ALL_LIMIT_MAX}, got {limit}"
            )));
        }
        let script = if deep {
            locate_deep_script(kind, value, Some(property))
        } else {
            locate_all_script(kind, value, Some(property))
        };
        let mut located = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
        if let Some(min) = wait_for {
            located = wait_for_matches(
                &conn,
                &cdp_session,
                &script,
                context_id,
                timeout,
                min,
                located,
            )?;
        }
        let total = located
            .get("matches")
            .and_then(Value::as_u64)
            .ok_or_else(|| CtlError::internal("enumeration script returned no total"))?;
        let elements = located
            .get("elements")
            .and_then(Value::as_array)
            .ok_or_else(|| CtlError::internal("enumeration script returned no elements"))?;
        let mut out = Vec::with_capacity(elements.len().min(limit));
        for element in elements.iter().take(limit) {
            let (text, truncated) = truncate_text(
                element
                    .get("text")
                    .and_then(Value::as_str)
                    .unwrap_or_default(),
            );
            let mut item = json!({ "text": text, "truncated": truncated });
            if let Some(tag) = element.get("tag").and_then(Value::as_str) {
                item["tag"] = Value::String(tag.to_string());
            }
            if let Some(id) = element.get("id").and_then(Value::as_str) {
                item["id"] = Value::String(id.to_string());
            }
            if property != "text" {
                item["property"] = Value::String(property.to_string());
                item["value"] = element.get("property").cloned().unwrap_or(Value::Null);
            }
            out.push(item);
        }
        let mut payload = json!({
            "total": total,
            "returned": out.len(),
            "truncated_by_limit": total > out.len() as u64,
            "selector_kind": kind.as_str(),
            "elements": out,
            "session_id": session_id,
            "frame": if iframe { "iframe" } else { "main" },
        });
        // --deep 的遍历预算触顶:matches 是下界,如实上报不谎称全量
        if deep {
            payload["deep"] = Value::Bool(true);
            if located.get("traversal_capped").and_then(Value::as_bool) == Some(true) {
                payload["traversal_capped"] = Value::Bool(true);
            }
        }
        return Ok(payload);
    }
    let script = if deep {
        locate_deep_script(kind, value, Some(property))
    } else {
        locate_script(kind, value, Some(property))
    };
    let mut located = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
    if let Some(min) = wait_for {
        located = wait_for_matches(
            &conn,
            &cdp_session,
            &script,
            context_id,
            timeout,
            min,
            located,
        )?;
    }
    require_unique_match(&located, kind, value)?;
    // --deep 脚本恒返回枚举形态:唯一命中取 elements[0] 作载荷
    let unique = if deep {
        located
            .get("elements")
            .and_then(Value::as_array)
            .and_then(|elements| elements.first())
            .cloned()
            .ok_or_else(|| CtlError::internal("deep script returned no element"))?
    } else {
        located.clone()
    };
    let mut payload = located_text_payload(&unique);
    if deep {
        payload["deep"] = Value::Bool(true);
    }
    if property != "text" {
        payload["property"] = Value::String(property.to_string());
        payload["value"] = unique.get("property").cloned().unwrap_or(Value::Null);
    }
    payload["session_id"] = Value::String(session_id.to_string());
    payload["frame"] = Value::String(if iframe { "iframe" } else { "main" }.to_string());
    Ok(payload)
}

// ---------------------------------------------------------------------------
// click(守卫写:停止核验 → 绑定重核 → 唯一匹配 → 派发 → 读回)
// ---------------------------------------------------------------------------

pub fn click(
    paths: &Paths,
    session_id: &str,
    kind: SelectorKind,
    value: &str,
    iframe: bool,
    expect_text: Option<&str>,
    timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
    validate_selector(kind, value)?;
    if expect_text.is_some_and(str::is_empty) {
        return Err(protocol("--expect-text must be non-empty"));
    }
    // 停止代次核验:与桌面写操作同纪律(急停压过一切写通道)
    actl_core::stop::check_current()?;
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    let record = session::load(paths, session_id)?;
    let conn = connect_browser(&record)?;
    let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
    verify_target_alive(&conn, &record.target_id, timeout)?;
    let context_id = if iframe {
        Some(iframe_context(&conn, &cdp_session, timeout)?.1)
    } else {
        None
    };
    let mut action = declare_action("browser-click", &format!("cdp:{session_id}"));
    action.executing();

    let located = eval_json(
        &conn,
        &cdp_session,
        &locate_script(kind, value, None),
        context_id,
        timeout,
    )?;
    require_unique_match(&located, kind, value)?;

    // 派发点击并读回(写后核验;一次写后必须 recheck,不盲重放)
    let embedded = serde_json::to_string(value).unwrap_or_default();
    let click_script = match kind {
        SelectorKind::Css => format!(
            "(function(){{var els=document.querySelectorAll({embedded});\
             if(els.length!==1)return JSON.stringify({{clicked:false,matches:els.length}});\
             els[0].click();return JSON.stringify({{clicked:true}});}})()"
        ),
        SelectorKind::Xpath => format!(
            "(function(){{var it=document.evaluate({embedded},document,null,\
             XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
             if(it.snapshotLength!==1)return JSON.stringify({{clicked:false,matches:it.snapshotLength}});\
             it.snapshotItem(0).click();return JSON.stringify({{clicked:true}});}})()"
        ),
    };
    let clicked = eval_json(&conn, &cdp_session, &click_script, context_id, timeout)?;
    if clicked.get("clicked").and_then(Value::as_bool) != Some(true) {
        return Err(CtlError::with_evidence(
            ErrorCode::NotActionable,
            "element resolved but click did not dispatch (DOM changed between locate and click)"
                .to_string(),
            json!({ "clicked": clicked }),
        ));
    }

    // 读回:同选择器重定位取文本
    let after = eval_json(
        &conn,
        &cdp_session,
        &locate_script(kind, value, None),
        context_id,
        timeout,
    )?;
    let (text_after, truncated) = truncate_text(
        after
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or_default(),
    );
    let state = eval_json(
        &conn,
        &cdp_session,
        "(function(){return JSON.stringify({url:location.href})})()",
        None,
        timeout,
    )?;
    let url = state
        .get("url")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();

    if let Some(expected) = expect_text
        && !text_after.contains(expected)
    {
        return Err(CtlError::with_evidence(
            ErrorCode::AssertionFailed,
            "post-click text does not contain the expected substring".to_string(),
            json!({
                "expected_contains": expected,
                "actual": text_after,
                "truncated": truncated,
            }),
        ));
    }
    action.delivered();
    Ok(json!({
        "session_id": session_id,
        "clicked": true,
        "text_after": text_after,
        "truncated": truncated,
        "url": url,
        "frame": if iframe { "iframe" } else { "main" },
    }))
}

// ---------------------------------------------------------------------------
// close
// ---------------------------------------------------------------------------

/// 守卫输入写:input/textarea 赋值(native setter + input/change 事件,React 受控组件
/// 兼容),读回 value 必须与请求一致(不一致=ASSERTION_FAILED,受控组件改写了值)。
/// 与 click 同纪律:停止核验→绑定重核→唯一匹配→派发→读回。
pub fn type_text(
    paths: &Paths,
    session_id: &str,
    kind: SelectorKind,
    value: &str,
    text: &str,
    iframe: bool,
    timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
    validate_selector(kind, value)?;
    if text.is_empty() || text.len() > TEXT_LIMIT {
        return Err(protocol("--text requires 1-65536 characters"));
    }
    actl_core::stop::check_current()?;
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    let record = session::load(paths, session_id)?;
    let conn = connect_browser(&record)?;
    let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
    verify_target_alive(&conn, &record.target_id, timeout)?;
    let context_id = if iframe {
        Some(iframe_context(&conn, &cdp_session, timeout)?.1)
    } else {
        None
    };
    let mut action = declare_action("browser-type", &format!("cdp:{session_id}"));
    action.executing();
    let located = eval_json(
        &conn,
        &cdp_session,
        &locate_script(kind, value, Some("value")),
        context_id,
        timeout,
    )?;
    require_unique_match(&located, kind, value)?;
    if located
        .get("tag")
        .and_then(Value::as_str)
        .is_none_or(|t| !matches!(t.to_ascii_uppercase().as_str(), "INPUT" | "TEXTAREA"))
    {
        return Err(CtlError::with_evidence(
            ErrorCode::NotActionable,
            "browser-type targets input/textarea elements only".to_string(),
            json!({ "tag": located.get("tag") }),
        ));
    }

    let embedded_sel = serde_json::to_string(value).unwrap_or_default();
    let embedded_text = serde_json::to_string(text).unwrap_or_default();
    let locate_expr = match kind {
        SelectorKind::Css => format!("document.querySelectorAll({embedded_sel})[0]"),
        SelectorKind::Xpath => format!(
            "document.evaluate({embedded_sel},document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null).snapshotItem(0)"
        ),
    };
    let script = format!(
        "(function(){{var e={locate_expr};\
         if(!e)return JSON.stringify({{typed:false,reason:'gone'}});\
         var tag=(e.tagName||'').toUpperCase();\
         if(tag!=='INPUT'&&tag!=='TEXTAREA')return JSON.stringify({{typed:false,reason:'not_input'}});\
         var proto=tag==='INPUT'?window.HTMLInputElement.prototype:window.HTMLTextAreaElement.prototype;\
         var d=Object.getOwnPropertyDescriptor(proto,'value');\
         if(d&&d.set){{d.set.call(e,{embedded_text});}}else{{e.value={embedded_text};}}\
         e.dispatchEvent(new Event('input',{{bubbles:true}}));\
         e.dispatchEvent(new Event('change',{{bubbles:true}}));\
         return JSON.stringify({{typed:true,value:String(e.value)}});}})()"
    );
    let typed = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
    if typed.get("typed").and_then(Value::as_bool) != Some(true) {
        return Err(CtlError::with_evidence(
            ErrorCode::NotActionable,
            "element resolved but typing did not dispatch (DOM changed between locate and type)"
                .to_string(),
            json!({ "typed": typed }),
        ));
    }
    let value_after = typed
        .get("value")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if value_after != text {
        return Err(CtlError::with_evidence(
            ErrorCode::AssertionFailed,
            "post-type value differs from the requested text (controlled input rewrote it)"
                .to_string(),
            json!({ "requested_len": text.len(), "actual_len": value_after.len() }),
        ));
    }
    action.delivered();
    // 不回显输入内容(动作日志不含文本纪律);需要时调用方 browser-read --property value 自取
    Ok(json!({
        "session_id": session_id,
        "typed": true,
        "value_after_len": value_after.len(),
        "frame": if iframe { "iframe" } else { "main" },
    }))
}

pub fn close(paths: &Paths, session_id: &str, timeout_ms: Option<u64>) -> Result<Value, CtlError> {
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    let record = session::load(paths, session_id)?;
    let mut action = declare_action("browser-close", &format!("cdp:{session_id}"));
    action.executing();

    let mut already_gone = false;
    match connect_browser(&record) {
        Ok(conn) => {
            conn.call("Browser.close", json!({}), None, timeout)
                .map_err(map_cdp)?;
            // 等连接断开(浏览器侧关闭会终结 websocket)
            let deadline = Instant::now() + timeout;
            while conn.is_connected() && Instant::now() < deadline {
                std::thread::sleep(Duration::from_millis(50));
            }
            // 端口探测兜底:连接断开但端口仍可达视为未收尾
            let port_dead = !TcpStream::connect_timeout(
                &format!("127.0.0.1:{}", record.devtools_port)
                    .parse()
                    .map_err(|_| CtlError::internal("bad devtools port in session"))?,
                Duration::from_millis(500),
            )
            .is_ok();
            if !conn.is_connected() && port_dead {
                finish_close(paths, &record)
            } else {
                Err(CtlError::with_evidence(
                    ErrorCode::Internal,
                    "Browser.close acknowledged but the endpoint still answers; \
                     session kept for retry",
                    json!({ "session_id": session_id, "devtools_port": record.devtools_port }),
                ))
            }
        }
        Err(e) => {
            // 端点本就不可达:幂等关闭(浏览器已死),如实标注
            already_gone = true;
            let _ = e;
            finish_close(paths, &record)
        }
    }
    .map(|mut value| {
        action.delivered();
        if already_gone {
            value["already_unreachable"] = Value::Bool(true);
        }
        value
    })
}

/// 只读枚举活跃会话(新→旧排序)。flow 的 outputs 只允许只读生产者,
/// browser-launch 是生命周期命令——会话句柄经本命令以观察者身份产出(docs/20 治理模型)。
/// browser-verify 的断言模式(CLI 层负责互斥校验后构造)。
pub enum VerifyMode {
    /// 最新会话 target 应答(launch 后)
    NewestAlive,
    /// 零活跃会话(close 后)
    NoneActive,
    /// 按 id:target 存活
    SessionAlive(String),
    /// 按 id:会话记录已注销
    SessionGone(String),
    /// 登录态核验谓词:选择器唯一解析(+文本包含)——resume_check 与例行登录检查的判据
    Selector {
        session: String,
        kind: SelectorKind,
        value: String,
        contains: Option<String>,
    },
    /// 页内弹层清零断言(2026-10-11 v1):枚举可见弹层表面,零命中=通过,
    /// 非零=ASSERTION_FAILED 带样本——flow condition 位映射 false
    /// (ASSERTION_FAILED→false 是既有语义),when/unless 分支可组合。
    /// 原生弹层门禁结构性不覆盖页内 DOM 弹层(browser-* 走 --session,
    /// front-report 只收 role:Window),本断言是那个空档的检测原语。
    NoPopups {
        session: String,
        selector: Option<String>,
    },
}

/// 默认弹层枚举选择器:标准弹层结构(<dialog open>/ARIA dialog/
/// alertdialog/aria-modal)。如实边界:无标注的自绘 fixed 浮层(cookie 条/
/// 推广层)不在默认面——站点特有横幅走 --popup-selector 显式声明;
/// 在流内的静态 role=dialog 段落会过检(过度检测由 samples 证据暴露,
/// 调用方收窄选择器)。
pub const DEFAULT_POPUP_SELECTOR: &str =
    "dialog[open],[role=dialog],[role=alertdialog],[aria-modal=true]";

/// 只读断言动词(flow 的 verify 位只认断言族;浏览器步骤的副作用核验入口)。
pub fn verify(paths: &Paths, mode: VerifyMode, timeout_ms: Option<u64>) -> Result<Value, CtlError> {
    let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
    match mode {
        VerifyMode::NoneActive => {
            let total = list(paths)?
                .get("total")
                .and_then(Value::as_u64)
                .unwrap_or(0);
            if total != 0 {
                return Err(CtlError::with_evidence(
                    ErrorCode::AssertionFailed,
                    format!("{total} browser sessions still active"),
                    json!({ "total": total }),
                ));
            }
            Ok(json!({ "assertion": "none", "passed": true }))
        }
        VerifyMode::NewestAlive => {
            let sessions = list(paths)?;
            let newest = sessions.pointer("/sessions/0").cloned().ok_or_else(|| {
                CtlError::with_evidence(
                    ErrorCode::AssertionFailed,
                    "no active browser session to assert",
                    json!({ "total": 0 }),
                )
            })?;
            let id = newest["session_id"]
                .as_str()
                .unwrap_or_default()
                .to_string();
            let record = session::load(paths, &id)?;
            let conn = connect_browser(&record)?;
            // attach 本身即端点+target 存活的第一重核验
            attach_target(&conn, &record.target_id, timeout)?;
            verify_target_alive(&conn, &record.target_id, timeout)?;
            Ok(json!({ "assertion": "newest_alive", "passed": true, "session_id": id }))
        }
        VerifyMode::SessionGone(session_id) => match session::load(paths, &session_id) {
            Err(e) if e.code == ErrorCode::NotFound => Ok(json!({
                "assertion": "gone",
                "passed": true,
                "session_id": session_id
            })),
            Ok(_) => Err(CtlError::with_evidence(
                ErrorCode::AssertionFailed,
                format!("browser session {session_id} still registered"),
                json!({ "session_id": session_id }),
            )),
            Err(e) => Err(e),
        },
        VerifyMode::SessionAlive(session_id) => {
            let record = session::load(paths, &session_id)?;
            let conn = connect_browser(&record)?;
            attach_target(&conn, &record.target_id, timeout)?;
            verify_target_alive(&conn, &record.target_id, timeout)?;
            Ok(json!({ "assertion": "alive", "passed": true, "session_id": session_id }))
        }
        VerifyMode::Selector {
            session,
            kind,
            value,
            contains,
        } => {
            validate_selector(kind, &value)?;
            let record = session::load(paths, &session)?;
            let conn = connect_browser(&record)?;
            let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
            verify_target_alive(&conn, &record.target_id, timeout)?;
            let located = eval_json(
                &conn,
                &cdp_session,
                &locate_script(kind, &value, None),
                None,
                timeout,
            )?;
            let matches = located.get("matches").and_then(Value::as_u64).unwrap_or(0);
            if matches != 1 {
                return Err(CtlError::with_evidence(
                    ErrorCode::AssertionFailed,
                    format!(
                        "login-state selector did not resolve uniquely ({matches} matches; expected 1)"
                    ),
                    json!({
                        "selector_kind": kind.as_str(),
                        "selector": value,
                        "matches": matches,
                    }),
                ));
            }
            let (text, truncated) = truncate_text(
                located
                    .get("text")
                    .and_then(Value::as_str)
                    .unwrap_or_default(),
            );
            if let Some(expected) = &contains
                && !text.contains(expected.as_str())
            {
                return Err(CtlError::with_evidence(
                    ErrorCode::AssertionFailed,
                    "login-state selector text does not contain the expected substring",
                    json!({
                        "selector_kind": kind.as_str(),
                        "selector": value,
                        "expected_contains": expected,
                        "actual": text,
                        "truncated": truncated,
                    }),
                ));
            }
            Ok(json!({
                "assertion": "selector",
                "passed": true,
                "session_id": session,
                "text": text,
                "truncated": truncated,
            }))
        }
        VerifyMode::NoPopups { session, selector } => {
            let sel = selector.unwrap_or_else(|| DEFAULT_POPUP_SELECTOR.to_string());
            if sel.is_empty() || sel.chars().count() > 4096 {
                return Err(CtlError::protocol("popup selector must be 1-4096 chars"));
            }
            let record = session::load(paths, &session)?;
            let conn = connect_browser(&record)?;
            let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
            verify_target_alive(&conn, &record.target_id, timeout)?;
            let scanned = eval_json(&conn, &cdp_session, &popup_scan_script(&sel), None, timeout)?;
            // 选择器语法错误如实 PROTOCOL——吞错成 total=0 等于谎称「无弹层」
            if let Some(err) = scanned.get("error").and_then(Value::as_str) {
                return Err(CtlError::protocol(format!(
                    "popup selector is not valid CSS: {err}"
                )));
            }
            let total = scanned.get("total").and_then(Value::as_u64).unwrap_or(0);
            if total > 0 {
                return Err(CtlError::with_evidence(
                    ErrorCode::AssertionFailed,
                    format!("{total} visible in-page popup surface(s) present"),
                    json!({
                        "assertion": "no_popups",
                        "selector": sel,
                        "total": total,
                        "samples": scanned.get("samples").cloned().unwrap_or(Value::Null),
                        "session_id": session,
                    }),
                ));
            }
            Ok(json!({
                "assertion": "no_popups",
                "passed": true,
                "selector": sel,
                "total": 0,
                "session_id": session,
            }))
        }
    }
}

pub fn list(paths: &Paths) -> Result<Value, CtlError> {
    let dir = paths.sessions_dir();
    let mut sessions: Vec<BrowserSession> = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(json!({ "sessions": [], "total": 0 }));
        }
        Err(e) => return Err(CtlError::internal(format!("cannot read sessions dir: {e}"))),
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().is_none_or(|x| x != "json") {
            continue;
        }
        let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
            continue;
        };
        // load 失败的损坏文件跳过:枚举不因单条坏记录失败
        if let Ok(s) = session::load(paths, id) {
            sessions.push(s);
        }
    }
    sessions.sort_by_key(|s| std::cmp::Reverse(s.created_unix));
    let list: Vec<Value> = sessions
        .iter()
        .map(|s| {
            json!({
                "session_id": s.session_id,
                "target_id": s.target_id,
                "url": s.bound_url,
                "browser_pid": s.pid,
                "devtools_port": s.devtools_port,
                "mode": s.mode,
                "headless": s.headless,
                "profile_dir": s.profile_dir,
                "created_unix": s.created_unix,
            })
        })
        .collect();
    let total = list.len();
    Ok(json!({ "sessions": list, "total": total }))
}

fn finish_close(paths: &Paths, record: &BrowserSession) -> Result<Value, CtlError> {
    session::remove(paths, &record.session_id)?;
    let (kept, removed) = match &record.mode {
        Mode::Persistent { .. } => (true, None),
        Mode::Ephemeral => {
            let removed = std::fs::remove_dir_all(&record.profile_dir).is_ok();
            (false, Some(removed))
        }
    };
    Ok(json!({
        "session_id": record.session_id,
        "closed": true,
        "mode": record.mode,
        "profile_kept": kept,
        "ephemeral_profile_removed": removed,
        "browser_pid": record.pid,
    }))
}

use std::net::TcpStream;