Cloud brain uses a model on the person's network through their computer; the chosen computer is where a bot works

- A brain that can't reach a model at a private address (e.g. Qwen at 10.x) sends the request
  through its hands; the hands only forward to addresses on their own network, and only for
  their brain (uses the existing device channel, so the account server is unchanged).
- The computer chosen for a bot is binding: a step naming another computer goes to the chosen
  one unless the person's message names that one (「在我的 Mac 上」).
- Browser steps follow the computer: Bot 的電腦 drives the Chromium on its own desktop (through
  docker exec, no new port), 你的電腦 its browser; before, they always used this computer's.

Checked: on this Mac, Bot 的電腦 browsing opened example.com in the box's Chromium and an
unasked 你的電腦 command ran in the box; 「在我的 Mac 上」 ran on the Mac. Linux brain in Docker
with 10.0.0.0/8 blocked + this Mac as hands: model calls went through the Mac, shell on
你的電腦 answered from the Mac, browser defaulted to the Mac's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 15:00:31 +08:00
parent 4c0db53647
commit c1e4994219
6 changed files with 318 additions and 20 deletions

View File

@ -114,6 +114,65 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
}) })
} }
enum BrowserAt {
Here,
Box,
Device(String),
}
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
if name == "web_fetch" {
return BrowserAt::Here;
}
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
let any_device = || job.computers.iter().find_map(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
// No browser here (a brain on a server): the 手腳 computer's.
_ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
_ => BrowserAt::Here,
}
}
/// Words that mean a computer in the person's message.
fn mentions(text: &str, t: &tools::Target) -> bool {
let low = text.to_lowercase();
if low.contains(&t.name.to_lowercase()) {
return true;
}
let words: &[&str] = match &t.place {
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && low.contains(&crate::remote::peer_name(host).to_lowercase()),
_ => &[],
};
words.iter().any(|w| low.contains(w))
}
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
/// to the chosen one.
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
let mut v = tools::args_of(args);
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
return args.to_string();
}
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
match job.computers.iter().find(|t| t.name == named) {
Some(t) if mentions(&said, t) => args.to_string(),
_ => {
v["computer"] = serde_json::Value::String(job.default_computer.clone());
v.to_string()
}
}
}
/// Does the call name a computer? /// Does the call name a computer?
fn names_a_computer(args: &str) -> bool { fn names_a_computer(args: &str) -> bool {
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false) tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
@ -288,6 +347,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if cancel.load(Ordering::Relaxed) { if cancel.load(Ordering::Relaxed) {
return; return;
} }
let mut out = out;
for c in out.tool_calls.iter_mut() {
c.args = keep_to_chosen(&job, &history, &c.args);
}
let msg = Message { let msg = Message {
role: Role::Assistant, role: Role::Assistant,
text: crate::i18n::reply_text(&out.text), text: crate::i18n::reply_text(&out.text),
@ -309,6 +372,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
} }
continue; continue;
} }
// (Each call's computer was already kept to the chosen one, see keep_to_chosen.)
for call in calls { for call in calls {
if appctl::is_app_tool(&call.name) { if appctl::is_app_tool(&call.name) {
// App actions run on the window's side, which also asks for deletions. // App actions run on the window's side, which also asks for deletions.
@ -347,12 +411,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) { if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins). // 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") { let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)), Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None), Err(e) => (e, false, None),
}, },
None => web::run_full(&call.name, &call.args, &cancel), BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
}; };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output); let mut result = Message::tool_result(&call.id, &call.name, &output);

View File

@ -3722,7 +3722,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
), ),
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!( tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的 Mac」「我的電腦」「這台」都是指它。", "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
t.name, t.name,
crate::remote::peer_name(host), crate::remote::peer_name(host),
t.name t.name
@ -3732,7 +3732,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
.collect(); .collect();
s.push_str(&format!( s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
- 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\ - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\

View File

@ -61,6 +61,9 @@ pub fn engine() -> Option<(&'static str, &'static str)> {
} }
pub fn available() -> bool { pub fn available() -> bool {
if using_box() {
return true;
}
engine().is_some() engine().is_some()
} }
@ -132,6 +135,9 @@ pub fn match_profile(list: &[String], want: &str) -> Option<String> {
/// Which profile a tool call means: the one named, or the current one. /// Which profile a tool call means: the one named, or the current one.
pub fn resolve(requested: Option<&str>) -> Result<String, String> { pub fn resolve(requested: Option<&str>) -> Result<String, String> {
if using_box() {
return Ok(DEFAULT_PROFILE.to_string());
}
match requested.map(str::trim).filter(|s| !s.is_empty()) { match requested.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(current()), None => Ok(current()),
Some(r) => { Some(r) => {
@ -231,8 +237,35 @@ pub fn encode_component(s: &str) -> String {
.collect() .collect()
} }
thread_local! {
/// Browser steps on this thread go to Bot 的電腦's Chromium (its box), not this computer's.
static IN_BOX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Run `f` with the browser steps on Bot 的電腦's own Chromium.
pub fn in_box<T>(f: impl FnOnce() -> T) -> T {
IN_BOX.with(|b| b.set(true));
let r = f();
IN_BOX.with(|b| b.set(false));
r
}
pub fn using_box() -> bool {
IN_BOX.with(|b| b.get())
}
/// Start this profile's window if it isn't running. Returns (browser name, port, just started). /// Start this profile's window if it isn't running. Returns (browser name, port, just started).
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
if using_box() {
let fresh = !crate::cloudbox::browser_running();
let port = crate::cloudbox::browser_port()?;
if fresh {
if let Some(u) = first_url {
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
}
}
return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some()));
}
let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
if profile == PERSON_PROFILE { if profile == PERSON_PROFILE {
return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into()); return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into());

View File

@ -541,6 +541,73 @@ fn wait_ready(ms: u64) -> Result<(), String> {
Err("電腦開機逾時".into()) Err("電腦開機逾時".into())
} }
// ---------- the box's own browser ----------
// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the
// box's window). Its DevTools port stays inside the box; a local port reaches it through
// `docker exec` (no published port, so it also works with a Docker host elsewhere).
const BOX_CDP_PORT: u16 = 9222;
fn box_cdp_alive(local: u16) -> bool {
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
}
/// A local port that reaches the box's DevTools port.
fn box_cdp_forward() -> Result<u16, String> {
static PORT: Mutex<u16> = Mutex::new(0);
let mut p = PORT.lock().unwrap();
if *p != 0 {
return Ok(*p);
}
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = l.local_addr().map_err(|e| e.to_string())?.port();
std::thread::spawn(move || {
for conn in l.incoming().flatten() {
std::thread::spawn(move || {
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null");
let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return };
let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return };
let up = std::thread::spawn(move || {
let _ = std::io::copy(&mut r, &mut cin);
});
let _ = std::io::copy(&mut cout, &mut w);
let _ = conn.shutdown(std::net::Shutdown::Both);
let _ = child.kill();
let _ = up.join();
let _ = child.wait();
});
}
});
*p = port;
Ok(port)
}
/// Is the box's Chromium answering already?
pub fn browser_running() -> bool {
box_cdp_forward().map(box_cdp_alive).unwrap_or(false)
}
/// The box's Chromium, started on its desktop when it isn't running. Gives the local port.
pub fn browser_port() -> Result<u16, String> {
ensure_running()?;
touch();
let port = box_cdp_forward()?;
if box_cdp_alive(port) {
return Ok(port);
}
let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}");
dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
let start = Instant::now();
while start.elapsed() < Duration::from_secs(20) {
if box_cdp_alive(port) {
return Ok(port);
}
std::thread::sleep(Duration::from_millis(300));
}
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(()); static START_LOCK: Mutex<()> = Mutex::new(());
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready. /// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.

View File

@ -414,8 +414,12 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
} }
} }
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> { fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
let reader = BufReader::new(body.into_reader()); read_sse_from(body.into_reader(), cancel, send, on_data)
}
fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
let reader = BufReader::new(r);
let mut event = String::new(); let mut event = String::new();
for line in reader.lines() { for line in reader.lines() {
if cancel.load(Ordering::Relaxed) { if cancel.load(Ordering::Relaxed) {
@ -750,29 +754,55 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>()); body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>());
} }
let url = format!("{}/chat/completions", req.base_url); let url = format!("{}/chat/completions", req.base_url);
let mut r = agent().post(&url).header("Content-Type", "application/json"); let mut headers: Vec<(String, String)> = vec![];
if !req.api_key.is_empty() { if !req.api_key.is_empty() {
r = r.header("Authorization", &format!("Bearer {}", req.api_key)); headers.push(("Authorization".into(), format!("Bearer {}", req.api_key)));
} }
if req.kind == ProviderKind::OpenRouter { if req.kind == ProviderKind::OpenRouter {
r = r.header("X-Title", "AnyBot"); headers.push(("X-Title".into(), "AnyBot".into()));
} }
if req.kind == ProviderKind::OpenCodeGo { if req.kind == ProviderKind::OpenCodeGo {
r = r.header("x-opencode-session", &opencode_session(req)); headers.push(("x-opencode-session".into(), opencode_session(req)));
}
// SAND-E0418 turnBackendUnreachable: retryable.
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
let status = resp.status().as_u16();
if status >= 400 {
let ra = retry_after_ms(&resp);
let text = resp.into_body().read_to_string().unwrap_or_default();
return Err(http_error(status, &text, ra));
} }
// A brain on a server, a model on the person's own network: through their computer (the
// whole answer comes back at once, so no word-by-word streaming).
let via_hands: Option<Vec<u8>> = match crate::remote::model_hands_for(&url) {
Some(hand) => {
let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?;
if status >= 400 {
return Err(http_error(status, &String::from_utf8_lossy(&bytes), None));
}
Some(bytes)
}
None => None,
};
let resp = match via_hands {
Some(_) => None,
None => {
let mut r = agent().post(&url).header("Content-Type", "application/json");
for (k, v) in &headers {
r = r.header(k, v);
}
// SAND-E0418 turnBackendUnreachable: retryable.
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
let status = resp.status().as_u16();
if status >= 400 {
let ra = retry_after_ms(&resp);
let text = resp.into_body().read_to_string().unwrap_or_default();
return Err(http_error(status, &text, ra));
}
Some(resp)
}
};
let mut out = StepOutput::default(); let mut out = StepOutput::default();
// Tool calls arrive in pieces keyed by index. // Tool calls arrive in pieces keyed by index.
let mut pending: Vec<(String, String, String)> = vec![]; let mut pending: Vec<(String, String, String)> = vec![];
let mut error = None; let mut error = None;
read_sse(resp.into_body(), cancel, send, |_ev, data| { let reader: Box<dyn std::io::Read> = match (resp, via_hands) {
(Some(r), _) => Box::new(r.into_body().into_reader()),
(None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())),
};
read_sse_from(reader, cancel, send, |_ev, data| {
if data == "[DONE]" { if data == "[DONE]" {
return false; return false;
} }

View File

@ -619,6 +619,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
let tool = v["tool"].as_str().unwrap_or(""); let tool = v["tool"].as_str().unwrap_or("");
let args = v["args"].as_str().unwrap_or("{}"); let args = v["args"].as_str().unwrap_or("{}");
let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
// The brain's model request to a model only this computer's network reaches.
if tool == MODEL_FETCH {
return model_fetch_here(args);
}
if crate::computer::is_computer_tool(tool) { if crate::computer::is_computer_tool(tool) {
let (text, ok, img) = crate::computer::run_by(tool, args, who); let (text, ok, img) = crate::computer::run_by(tool, args, who);
return Resp::json(&json!({"text": text, "ok": ok, "image": img})); return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
@ -645,6 +649,96 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
Resp::error(400, "這個工具不能遠端使用") Resp::error(400, "這個工具不能遠端使用")
} }
// ---------- a model on the person's network, for a brain on a server ----------
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
// home). Its hands can: the brain hands them the request and gets the whole answer back.
const MODEL_FETCH: &str = "model_fetch";
/// Private, link-local or loopback address (by name: localhost and *.local).
fn private_host(host: &str) -> bool {
let h = host.trim_matches(['[', ']']);
if h == "localhost" || h.ends_with(".local") {
return true;
}
match h.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(a)) => a.is_private() || a.is_loopback() || a.is_link_local(),
Ok(std::net::IpAddr::V6(a)) => a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00,
Err(_) => false,
}
}
fn host_port(url: &str) -> Option<(String, u16)> {
let (scheme, rest) = url.split_once("://")?;
let auth = rest.split('/').next()?;
let auth = auth.rsplit('@').next()?;
let (h, p) = match auth.rsplit_once(':') {
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
};
Some((h, p))
}
/// The hands to send a model request through: the model is at a private address this brain
/// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option<String> {
let s = hub().settings();
let hand = my_hands(&s.host_id).into_iter().next()?;
let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None;
}
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
let key = format!("{h}:{p}");
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
if at.elapsed().as_secs() < 300 {
return if *reach { None } else { Some(hand) };
}
}
use std::net::ToSocketAddrs;
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
if reach { None } else { Some(hand) }
}
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand);
let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?;
if let Some(e) = v["error"].as_str() {
return Err(format!("{whom}也連不到模型:{e}"));
}
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
}
/// On the hands: make the brain's model request, only to an address on this computer's network.
fn model_fetch_here(args: &str) -> Resp {
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
let url = v["url"].as_str().unwrap_or("");
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
}
let mut r = agent(600).post(url).header("Content-Type", "application/json");
for h in v["headers"].as_array().into_iter().flatten() {
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
r = r.header(k, val);
}
}
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
Ok(mut resp) => {
let status = resp.status().as_u16();
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
Resp::json(&json!({"status": status, "text": text}))
}
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
}
}
// ---------- the local server ---------- // ---------- the local server ----------
static LAN_GEN: AtomicU64 = AtomicU64::new(0); static LAN_GEN: AtomicU64 = AtomicU64::new(0);
@ -1273,6 +1367,15 @@ pub fn page() -> Resp {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn model_requests_go_only_to_private_addresses() {
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
}
#[test] #[test]
fn phone_login_and_api() { fn phone_login_and_api() {
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() }; let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };