From 4605b317d85cbfea9fc632483eef5e3361f299ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 7 Nov 2026 00:03:25 +0800 Subject: [PATCH] =?UTF-8?q?Bot=20=E7=9A=84=E9=9B=BB=E8=85=A6=20can=20run?= =?UTF-8?q?=20under=20gVisor:=20its=20own=20kernel,=20Chromium's=20sandbox?= =?UTF-8?q?=20back=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容器隔離 in 設定 › 電腦: standard (runc, the container is the only wall, Chromium runs --no-sandbox) or hardened (runsc: a virtualized kernel, all capabilities dropped but the few the desktop needs, ANYBOT_BOX_SANDBOX=1 turns Chromium's own sandbox back on). Box image v7. deploy/mac/gvisor-vm.sh makes a Lima VM with Docker + gVisor for a Mac (started at login), gvisor-migrate.sh moves the box's files and logins there. Checked and finished before committing: - the hardened choice is offered only on a Docker host that has runsc; a box set to it on a host without it opens the standard way and says so (it used to not start at all) - the settings row shows what the box really runs on, and when 更新 is needed - no swap only under gVisor (a standard box keeps Docker's default) - the migrate script sees the running Mac app ("AnyBot", not "anybot") - the VM script starts an existing VM by name, checks runsc and the kernel from the Mac - the version test says 7 Real run in the Lima VM: desktop, VNC, numbered controls, xdotool and screenshots work; no Chromium process has --no-sandbox, renderers run with seccomp and their own namespaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- FEATURES.md | 1 + box/Dockerfile | 16 ++++-- box/browser | 7 ++- deploy/mac/gvisor-migrate.sh | 56 +++++++++++++++++++++ deploy/mac/gvisor-vm.sh | 83 ++++++++++++++++++++++++++++++++ deploy/server/docker-compose.yml | 7 +++ docs/architecture.md | 2 +- src/app/computers_ui.rs | 46 +++++++++++++++++- src/cloudbox.rs | 76 ++++++++++++++++++++++++++++- 9 files changed, 285 insertions(+), 9 deletions(-) create mode 100755 deploy/mac/gvisor-migrate.sh create mode 100755 deploy/mac/gvisor-vm.sh diff --git a/FEATURES.md b/FEATURES.md index 7d0814c..887ef92 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -729,6 +729,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。 - 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。 - 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。 +- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(登入時自動啟動),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。 - 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。 - **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 diff --git a/box/Dockerfile b/box/Dockerfile index 853b732..70c91a0 100644 --- a/box/Dockerfile +++ b/box/Dockerfile @@ -21,12 +21,20 @@ COPY overlay /etc/anybot/overlay COPY Xresources /etc/anybot/Xresources COPY menu /etc/anybot/menu RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \ - # Chromium has no sandbox inside a container (no user namespaces); the box itself is the - # sandbox. Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open). - && printf '%s\n' 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic --test-type"' > /etc/chromium.d/anybot + # Chromium has no sandbox inside a plain container (no user namespaces); the box itself is + # the sandbox. Under gVisor (容器隔離=runsc) the box has its own virtualized kernel and + # Chromium's sandbox works: AnyBot then starts the box with ANYBOT_BOX_SANDBOX=1 and the + # sandbox is switched back on (one more wall between a hostile web page and the machine). + # Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open). + && printf '%s\n' \ + 'if [ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ]; then' \ + ' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \ + 'else' \ + ' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --test-type --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \ + 'fi' > /etc/chromium.d/anybot # Accessibility on for every app, including those started over `docker exec`. ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0 EXPOSE 6080 -LABEL anybot.box.version="6" +LABEL anybot.box.version="7" ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/usr/local/bin/anybot-box-start"] diff --git a/box/browser b/box/browser index d78dee9..c4624d2 100644 --- a/box/browser +++ b/box/browser @@ -23,7 +23,12 @@ if [[ ! -e "$PROFILE" ]]; then ln -s /home/bot/.config/anybot-browser "$PROFILE" fi fi -FLAGS=(--no-sandbox --test-type --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 +# The box (a container) is the sandbox by default, so Chromium runs --no-sandbox. Under gVisor +# (容器隔離=runsc) the box has a virtualized kernel of its own and AnyBot starts it with +# ANYBOT_BOX_SANDBOX=1 — Chromium's own sandbox works there, so keep it as a second wall. +SANDBOX=(--no-sandbox --test-type) +[ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ] && SANDBOX=() +FLAGS=("${SANDBOX[@]}" --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled --disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic --no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble diff --git a/deploy/mac/gvisor-migrate.sh b/deploy/mac/gvisor-migrate.sh new file mode 100755 index 0000000..694a7b8 --- /dev/null +++ b/deploy/mac/gvisor-migrate.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# 把 Bot 的電腦整個搬到 gVisor VM:box 的全部檔案與瀏覽器登入都在 home 資料卷 +# (anybot-box-home,Chromium 的 profile 也在裡面),整卷複製過去後, +# AnyBot 在 VM 上掛同名資料卷開機=檔案、登入、桌面設定全部跟著走。 +# 所有 Bot 共用這一台、這一份登入與檔案:不分帳號,資料夾照常共用。 +# +# 先完全退出 AnyBot 應用程式,然後: +# ./gvisor-migrate.sh 搬過去(本機這份保留,當備份) +# ./gvisor-migrate.sh --check 只比對兩邊,不動資料 +set -euo pipefail +VM=anybot-gvisor +VOL=anybot-box-home +VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock" + +command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; } +if [ -n "${DOCKER_HOST:-}" ]; then + echo "現在環境裡有 DOCKER_HOST=$DOCKER_HOST(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1 +fi +docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 $VOL(box 還沒建立過?)"; exit 1; } +limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; } +# The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case. +if pgrep -ix anybot >/dev/null 2>&1; then + echo "AnyBot 還在跑:請先從選單完全退出(它會把 box 叫醒、資料卷會一直在用)。"; exit 1 +fi + +echo "本機 box 的檔案:$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h | wc -l') 個項目、$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'du -sh /h | cut -f1')" + +if [ "${1:-}" = "--check" ]; then + echo "VM 上的檔案:$(DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h 2>/dev/null | wc -l') 個項目" + exit 0 +fi + +echo "停止本機的 box(容器留在本機當備份,不刪)…" +docker stop anybot-box anybot-box-home-holder >/dev/null 2>&1 || true + +echo "VM 上準備同名資料卷…" +DOCKER_HOST=$VMHOST docker volume create "$VOL" >/dev/null + +echo "搬移中(本機 → VM,同一條管線直送,不落地)…" +# 兩邊都是 Linux,檔案擁有者(uid 1000 = box 裡的 bot 使用者)直接對得上。 +docker run --rm -v "$VOL":/h -w /h alpine:3.20 tar -cf - . \ + | DOCKER_HOST=$VMHOST docker run --rm -i -v "$VOL":/h -w /h alpine:3.20 tar -xf - + +echo "核對…" +DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'echo "VM 上的檔案:$(find /h | wc -l) 個項目、$(du -sh /h | cut -f1)"' + +cat </dev/null || true + echo "已移除 VM $VM。" + exit 0 +fi + +command -v limactl >/dev/null 2>&1 || { echo "先裝 Lima:brew install lima"; exit 1; } + +STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true) +if [ "$STATUS" = "Running" ]; then + echo "VM $VM 已在執行。" +elif [ -n "$STATUS" ]; then + echo "VM $VM 是 $STATUS,啟動中…" + limactl start --tty=false "$VM" +else + echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…" + # template:docker-rootful = Lima's rootful Docker template (runsc wants a plain root daemon; + # the rootless docker template runs the daemon in a user namespace). + limactl start --name="$VM" --tty=false --cpus "$CPUS" --memory "$MEM" --disk "$DISK" template:docker-rootful +fi + +echo "VM 裡安裝 gVisor(runsc,systrap 平台:不需要 KVM,ARM VM 可用)…" +limactl shell "$VM" -- bash -ec ' +set -e +if ! command -v runsc >/dev/null 2>&1; then + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq runsc +fi +# Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings). +sudo python3 - </dev/null 2>&1 || limactl start-at-login "$VM" --enabled >/dev/null 2>&1 || echo "(沒能設定開機自動啟動:Mac 重開機後要再跑一次這個腳本)" + +# Lima forwards the VM's Docker socket to the Mac: AnyBot's Docker 主機用這個 unix:// 位址, +# 不用 ssh、不用碰 ~/.ssh/config。 +SOCK="$HOME/.lima/$VM/sock/docker.sock" +DOCKER_HOST="unix://$SOCK" docker info --format '{{range $k, $v := .Runtimes}}{{$k}} {{end}}' | grep -qw runsc || { echo "從 Mac 連到 VM 的 Docker 看不到 runsc"; exit 1; } +KERNEL=$(DOCKER_HOST="unix://$SOCK" docker run --rm --runtime=runsc --entrypoint cat alpine:3.20 /proc/version | cut -d' ' -f3) + +cat <, secret_error: String, docker_host: Option, + /// 容器隔離 row: "runc" (standard) or "runsc" (gVisor). + box_runtime: String, + /// Checked in the background: the Docker host has gVisor, and the runtime the box runs on. + runtime_facts: Arc>>, show_log: bool, teach_name: String, teach_for: Option, @@ -51,6 +55,9 @@ pub struct ComputerUi { thumb_at: Option, } +/// (the Docker host has gVisor, the runtime the box runs on now) +type RuntimeFacts = (bool, Option); + fn status_color(r: &Run, p: Palette) -> Color32 { match r { Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70), @@ -97,7 +104,11 @@ impl App { if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) { self.cu.update_checked = Some(Instant::now()); let flag = self.cu.update_flag.clone(); - std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst)); + let facts = self.cu.runtime_facts.clone(); + std::thread::spawn(move || { + flag.store(cloudbox::update_available(), Ordering::SeqCst); + *facts.lock().unwrap() = Some((cloudbox::host_has_gvisor(), cloudbox::running_runtime())); + }); } self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst); ui.spacing_mut().item_spacing.y = 0.0; @@ -616,6 +627,39 @@ impl App { } ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace)); }); + let rt_now = cloudbox::box_runtime(); + let facts = self.cu.runtime_facts.lock().unwrap().clone(); + // What the box really runs on, and what pressing 更新 would change. + let status = match &facts { + Some((false, _)) if rt_now == "runsc" => "這台 Docker 主機沒有裝 gVisor:會用標準隔離開機".to_string(), + Some((_, Some(running))) if *running != rt_now => format!("目前是{};按電腦的「更新」重建後改成{}", if running == "runsc" { "強隔離" } else { "標準" }, if rt_now == "runsc" { "強隔離" } else { "標準" }), + Some((_, Some(running))) => format!("目前是{}", if running == "runsc" { "強隔離(gVisor)" } else { "標準" }), + _ => String::new(), + }; + let gvisor_here = facts.as_ref().map(|f| f.0).unwrap_or(false); + sec_row_s(ui, p, "容器隔離", &status, "標準:容器和這台 Docker 主機共用 Linux 核心,Bot 開網頁時只有一層牆。Docker 主機裝了 gVisor 才能選強隔離:電腦得到自己的虛擬核心,Chromium 的沙盒也會打開,多一道牆。改完按電腦的「更新」重建後生效。", |ui| { + let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() }; + let std_on = rt == "runc"; + let gvis_on = rt == "runsc"; + if ui.selectable_label(std_on, t("標準")).clicked() { + self.cu.box_runtime = "runc".into(); + } + let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner; + if g.clicked() { + self.cu.box_runtime = "runsc".into(); + } + g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)")); + if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now { + if ui.add(primary_button("套用", p)).clicked() { + cloudbox::set_box_runtime(&self.cu.box_runtime); + self.cu.box_runtime.clear(); + self.cu.update_checked = None; + } + if ui.add(egui::Button::new(RichText::new(t("取消")).size(FONT_XS).color(p.text3)).frame(false)).clicked() { + self.cu.box_runtime.clear(); + } + } + }); }); if apply { cloudbox::set_docker_host(edit.trim()); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 918e9fd..5bbec2c 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -167,6 +167,13 @@ pub struct Config { /// loopback-only, but the box's own programs can reach it, and they should need a key). #[serde(default)] pub vnc_password: String, + /// 容器隔離: the Docker runtime the box runs on. Empty = Docker's default (runc: the box + /// shares the host's kernel, and Chromium runs with its own sandbox off). On a Linux Docker + /// host with gVisor installed, "runsc" gives the box a virtualized kernel and Chromium's + /// sandbox comes back — a second wall for the pages bots open. Takes effect on the next + /// 更新/重設/復原 (the container is recreated). + #[serde(default)] + pub runtime: String, } /// A scheduled update that was missed may still run for this long (the original: one hour). @@ -312,6 +319,39 @@ fn save_config(c: &Config) { let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default()); } +/// 容器隔離: the box's Docker runtime — "runc" (the default: a container sharing the host's +/// kernel) or "runsc" (gVisor: a virtualized kernel and user-space seccomp, for Docker hosts +/// that have it installed). Anything else falls back to the default. +pub fn box_runtime() -> String { + match config().lock().unwrap().runtime.as_str() { + "runsc" => "runsc".into(), + _ => "runc".into(), + } +} + +/// The box's Docker host has gVisor (a runtime registered as runsc). +pub fn host_has_gvisor() -> bool { + dk(&["info", "--format", "{{range $k, $v := .Runtimes}}{{$k}} {{end}}"]).map(|o| o.split_whitespace().any(|r| r == "runsc")).unwrap_or(false) +} + +/// The runtime the box is running on now ("runc", "runsc"), when there is a box. +pub fn running_runtime() -> Option { + dk(&["inspect", "-f", "{{.HostConfig.Runtime}}", container()]).ok().map(|r| r.trim().to_string()).filter(|r| !r.is_empty()) +} + +/// Changing it takes effect on the box's next rebuild (更新/重設/復原), where the container +/// is created again — nothing is recreated here behind the person's back. +pub fn set_box_runtime(r: &str) { + let r = if r.trim() == "runsc" { "runsc" } else { "" }; + let mut c = config().lock().unwrap(); + if c.runtime == r { + return; + } + c.runtime = r.to_string(); + save_config(&c); + log(&format!("容器隔離改為「{}」,下次重建電腦(更新、重設或復原)後生效。", if r == "runsc" { "gVisor(虛擬核心)" } else { "標準(共用主機核心)" })); +} + pub fn egress_via_mac() -> bool { config().lock().unwrap().egress_via_mac } @@ -688,8 +728,40 @@ fn create_container() -> Result<(), String> { let env = format!("SCREEN_SIZE={SCREEN}"); let vnc = format!("VNC_PASSWORD={}", vnc_password()); log("正在開一台新的電腦…"); + // 容器隔離: on a Docker host with gVisor (`runsc`), the box gets a virtualized kernel + // instead of the host's, its seccomp filters run in user space, and Chromium's own sandbox + // works inside the box (ANYBOT_BOX_SANDBOX=1 re-enables it; see box/Dockerfile). With the + // plain runc runtime the box itself is the sandbox, like the original. + // A Docker host without gVisor (the setting moved with a new Docker host, or gVisor was + // removed) would leave no computer at all ("unknown runtime"): open it the standard way and + // say so; the settings page shows which one it runs on. + let hardened = box_runtime() == "runsc" && host_has_gvisor(); + if box_runtime() == "runsc" && !hardened { + log("這台 Docker 主機沒有 gVisor(runsc),電腦先用標準隔離開機;裝好 gVisor 後按「更新」重建。"); + } + let runtime = "runsc"; + let sandbox_env = "ANYBOT_BOX_SANDBOX=1"; + let caps: Vec = ["CHOWN", "SETUID", "SETGID", "FOWNER", "DAC_OVERRIDE", "KILL", "NET_BIND_SERVICE"] + .iter() + .flat_map(|c| ["--cap-add".to_string(), (*c).to_string()]) + .collect(); + let mut args: Vec<&str> = vec!["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc]; + if hardened { + // (No swap on top of the 2 GB under gVisor; a standard box keeps Docker's default.) + args.extend(["--memory-swap", "2g"]); + args.push("--runtime"); + args.push(runtime); + args.push("--cap-drop"); + args.push("ALL"); + for c in &caps { + args.push(c); + } + args.push("-e"); + args.push(sandbox_env); + } + args.extend(["--label", "anybot.box=1", IMAGE]); // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). - dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + dk(&args).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; ensure_holder(); Ok(()) } @@ -2513,7 +2585,7 @@ mod tests { assert!(!in_schedule_window("03:00", t("02:59"))); assert!(in_schedule_window("23:30", t("00:10"))); assert!(!in_schedule_window("bad", t("00:10"))); - assert_eq!(wanted_version(), "6"); + assert_eq!(wanted_version(), "7"); } #[test]