From d6cb74eff5e9a8e12e1f05324f553181d7fa7a9a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:32:39 +0800 Subject: [PATCH 1/5] feat(twcore): add `twcore upgrade` A core running on its own (on a server) had no way to move to another release short of downloading a file and copying it over the binary by hand. `twcore upgrade` asks GitHub for the latest release, or the one given with --version, downloads the build for this OS and architecture, checks its .sha256, runs the new file once to see it reports the version being installed, and renames it over the current executable. Every step before the rename leaves the old binary untouched, and the rename is atomic, so a failed or tampered download never leaves a broken file. --version installs even an older release: a server has to match the desktop app's version, which may be behind. --check only reports. --restart restarts twcore.service when systemd is running it; without it the command says how, since a restart cuts requests in flight. The copy inside the desktop app is refused: the app updates it itself, and the app and core have to come from one commit. No new dependencies: reqwest and sha2 are already in the tree. Tests run the whole flow against a local fake GitHub (checksum mismatch, a binary reporting the wrong version, a missing pinned release), replace a running binary under itself, and read release.yml to check the asset names it publishes are the ones upgrade looks for. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 + bin/twcore/Cargo.toml | 7 + bin/twcore/src/main.rs | 24 + bin/twcore/src/upgrade.rs | 901 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 937 insertions(+) create mode 100644 bin/twcore/src/upgrade.rs diff --git a/Cargo.lock b/Cargo.lock index 434c9a4d..dca0c1bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2816,13 +2816,18 @@ name = "twcore" version = "0.46.0" dependencies = [ "anyhow", + "axum", "chrono", "clap", "http-body-util", "hyper", "hyper-util", "libc", + "reqwest", + "serde", + "serde_json", "serde_yaml_ng", + "sha2", "tempfile", "tokio", "tracing", diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index c77752df..eb6f16db 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -32,6 +32,10 @@ hyper = { workspace = true } hyper-util = { workspace = true, features = ["tokio"] } http-body-util = { workspace = true } tracing-subscriber = { workspace = true } +# `twcore upgrade`:问 Release、下载、核对校验和。都是依赖树里已有的 +reqwest = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } [target.'cfg(unix)'.dependencies] # 只剩一处在用:问一个 pid 还在不在(见 src/proc.rs) @@ -45,3 +49,6 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] tempfile = "3" +# `twcore upgrade` 的测试对着一个假的 GitHub 跑 +axum = { workspace = true } +serde_json = { workspace = true } diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 563c9d86..7414a60b 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -12,6 +12,7 @@ use clap::{Parser, Subcommand}; mod call; mod lockfile; mod proc; +mod upgrade; use lockfile::{LockFile, LockOutcome}; @@ -111,6 +112,20 @@ enum Command { #[arg(short, long)] out: Option, }, + /// Replace this twcore with another release from GitHub; the configuration is left alone + // + // 只给单独装的 twcore 用(服务器上)。桌面应用包里的那份由应用更新,见 upgrade.rs + Upgrade { + /// Only compare with the release and report; change nothing + #[arg(long)] + check: bool, + /// Restart twcore.service afterwards when systemd runs it + #[arg(long)] + restart: bool, + /// Install this version rather than the latest, such as 0.47.0 + #[arg(long)] + version: Option, + }, } #[derive(Subcommand)] @@ -185,6 +200,15 @@ fn main() -> Result<()> { data, out, } => call::run(&path, &endpoint, &method, data, out), + Command::Upgrade { + check, + restart, + version, + } => upgrade::run(upgrade::Opts { + check, + restart, + version, + }), } } diff --git a/bin/twcore/src/upgrade.rs b/bin/twcore/src/upgrade.rs new file mode 100644 index 00000000..c2dcb45a --- /dev/null +++ b/bin/twcore/src/upgrade.rs @@ -0,0 +1,901 @@ +//! `twcore upgrade`:把这个二进制换成 GitHub Release 上的另一版。 +//! +//! **只给自己管自己的那种安装用** —— 服务器上 `scripts/install.sh` 装的、手工 +//! 放进 PATH 的。桌面应用包里的那一份由应用自己更新:换掉它,应用和 core 就 +//! 不是同一个 commit 出来的了(控制面协议是两边一起编的),所以在包里一律拒绝。 +//! +//! 一次升级的顺序,每一步失败都停在原地、什么都没动: +//! +//! 1. 问 Release(最新的,或 `--version` 指定的那一版),和自己的版本比; +//! 2. 能不能写可执行文件所在的目录 —— **下载之前就问**,免得下了二十兆才说要 sudo; +//! 3. 下载本平台的二进制和它的 `.sha256`,核对; +//! 4. 写进同目录的临时文件,跑一次 `--version`,报的得是要装的那一版; +//! 5. 改名盖过去。unix 上 rename 是原子的:任何时刻那个路径上要么是旧的、要么 +//! 是新的,正在跑的进程拿着旧的 inode 照常跑。 +//! +//! 配置和数据一概不碰。换完之后正在跑的服务还是旧版,重启它由人决定(或 `--restart`)。 + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +const REPO: &str = "ThinkWatchProject/ThinkWatch-Core"; +const API: &str = "https://api.github.com"; +/// install.sh 装的那个 unit +const UNIT: &str = "twcore"; + +pub struct Opts { + pub check: bool, + pub restart: bool, + pub version: Option, +} + +/// 三段数字的版本号。**不认预发布后缀** —— 这个项目不发预发布版,认了反而 +/// 要回答「0.47.0-rc1 比 0.47.0 新吗」这种用不上的问题。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version(u64, u64, u64); + +impl std::str::FromStr for Version { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + let t = s.trim(); + let t = t.strip_prefix('v').unwrap_or(t); + let parts: Vec<&str> = t.split('.').collect(); + let n = |p: &str| p.parse::().ok(); + match parts.as_slice() { + [a, b, c] => match (n(a), n(b), n(c)) { + (Some(a), Some(b), Some(c)) => Ok(Version(a, b, c)), + _ => bail!("`{s}` is not a version such as 0.47.0"), + }, + _ => bail!("`{s}` is not a version such as 0.47.0"), + } + } +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.0, self.1, self.2) + } +} + +/// 这个平台在 Release 里对应哪个构建。**和 release.yml 发的那几个一一对应**, +/// 没发的平台(Intel Mac)返回 `None`。 +pub fn target_for(os: &str, arch: &str) -> Option<&'static str> { + Some(match (os, arch) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + ("windows", "aarch64") => "aarch64-pc-windows-msvc", + _ => return None, + }) +} + +/// Release 里的文件名:裸二进制,Windows 带 `.exe`。 +pub fn asset_name(target: &str) -> String { + if target.contains("windows") { + format!("twcore-{target}.exe") + } else { + format!("twcore-{target}") + } +} + +#[derive(Debug, Deserialize)] +pub struct Release { + pub tag_name: String, + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Asset { + pub name: String, + pub browser_download_url: String, +} + +impl Release { + pub fn version(&self) -> Result { + self.tag_name.parse() + } + + /// 二进制和它的校验文件的下载地址。**两个缺一个都不装**:没有校验和的 + /// 二进制,装上去的是什么没人说得清。 + pub fn pick(&self, asset: &str) -> Result<(&str, &str)> { + let url = |name: &str| { + self.assets + .iter() + .find(|a| a.name == name) + .map(|a| a.browser_download_url.as_str()) + }; + let sha = format!("{asset}.sha256"); + match (url(asset), url(&sha)) { + (Some(b), Some(s)) => Ok((b, s)), + (None, _) => bail!( + "release {} has no {asset}, so there is no build for this machine in it", + self.tag_name + ), + (Some(_), None) => bail!( + "release {} has {asset} but not {sha}, so it cannot be verified", + self.tag_name + ), + } + } +} + +/// ` <文件名>` 里的那串十六进制,和下载到的字节比。 +pub fn verify(bytes: &[u8], sha_file: &str) -> Result<()> { + let want = sha_file + .split_whitespace() + .next() + .map(str::to_ascii_lowercase) + .filter(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit())) + .ok_or_else(|| anyhow!("the checksum file does not start with a SHA-256"))?; + let got: String = Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + if got != want { + bail!("the download's SHA-256 is {got}, and the release says {want}; nothing was changed"); + } + Ok(()) +} + +/// 这个可执行文件是不是桌面应用包里的那一份。 +/// +/// 三个平台的包各长各的样,认的是路径上的形状:macOS 的 `.app`;Linux 的 +/// AppImage 挂载点(`/tmp/.mount_…`)和它里面的 `ThinkWatch Lite` 目录;Windows 的 +/// 安装目录 `ThinkWatch Lite`。 +pub fn in_app_bundle(exe: &Path) -> bool { + exe.components().any(|c| { + let s = c.as_os_str().to_string_lossy(); + s.ends_with(".app") || s == "ThinkWatch Lite" || s.starts_with(".mount_") + }) +} + +/// 在目标旁边写一个临时文件,**同一个目录** —— rename 只在同一个文件系统上是原子的。 +/// +/// 扩展名留在最后(`.twcore.upgrade-123.exe`):Windows 按扩展名认可执行文件, +/// 下面那次 `--version` 要能跑起来。 +fn staging_path(exe: &Path) -> PathBuf { + let stem = exe + .file_stem() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "twcore".into()); + let ext = exe + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + exe.with_file_name(format!(".{stem}.upgrade-{}{ext}", std::process::id())) +} + +/// 能不能在这个目录里写。**下载之前问**,答案是「要 sudo」时不必先下二十兆。 +pub fn check_writable(exe: &Path) -> Result<()> { + let probe = staging_path(exe); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!( + "{} cannot be written by this user. Run the upgrade with sudo", + exe.parent().unwrap_or(exe).display() + ), + Err(e) => Err(e).with_context(|| format!("writing next to {}", exe.display())), + } +} + +/// 写新文件、`check` 一下、盖过去。`check` 拿到的是临时文件的路径。 +/// +/// **任何一步失败,原来那个文件原样还在。** +pub fn replace(exe: &Path, bytes: &[u8], check: impl FnOnce(&Path) -> Result<()>) -> Result<()> { + let tmp = staging_path(exe); + let r = (|| { + write_executable(&tmp, bytes)?; + check(&tmp)?; + swap(&tmp, exe) + })(); + if r.is_err() { + let _ = std::fs::remove_file(&tmp); + } + r +} + +fn write_executable(path: &Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + let mut f = + std::fs::File::create(path).with_context(|| format!("creating {}", path.display()))?; + f.write_all(bytes)?; + // 落盘再改名:先改名后断电,路径上就是一个半截文件 + f.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +#[cfg(unix)] +fn swap(new: &Path, exe: &Path) -> Result<()> { + std::fs::rename(new, exe).with_context(|| format!("replacing {}", exe.display())) +} + +/// Windows 不让覆盖一个正在跑的 exe,但让它改名。所以先把旧的挪开、再把新的 +/// 挪进来;第二步失败就挪回去。挪开的那个下次升级时删掉(那时它已经不在跑了)。 +#[cfg(windows)] +fn swap(new: &Path, exe: &Path) -> Result<()> { + let old = exe.with_extension("exe.old"); + let _ = std::fs::remove_file(&old); + std::fs::rename(exe, &old).with_context(|| format!("moving {} aside", exe.display()))?; + if let Err(e) = std::fs::rename(new, exe) { + let _ = std::fs::rename(&old, exe); + return Err(e).with_context(|| format!("replacing {}", exe.display())); + } + Ok(()) +} + +/// 跑一次新文件的 `--version`,报的得是要装的那一版。 +/// +/// 编得过但起不来的二进制(架构不对、glibc 太旧)在这里就露出来,而不是在 +/// 服务重启之后。 +fn runs_as(path: &Path, want: Version) -> Result<()> { + let out = run_fresh(std::process::Command::new(path).arg("--version")).with_context(|| { + format!( + "the downloaded twcore does not run on this machine ({})", + path.display() + ) + })?; + let text = String::from_utf8_lossy(&out.stdout); + let got = text + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()); + if !out.status.success() || got != Some(want) { + bail!( + "the downloaded twcore reports `{}` rather than {want}; nothing was changed", + text.trim() + ); + } + Ok(()) +} + +/// 跑一个刚写完的文件。 +/// +/// Linux 上,别的线程正好在 fork 的那一瞬间会连带拿着这个文件的写句柄, +/// exec 就报 `ETXTBSY`(文件正被写)—— 句柄随 exec 关掉,稍等再试就好。 +fn run_fresh(cmd: &mut std::process::Command) -> std::io::Result { + let mut tries = 0; + loop { + match cmd.output() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && tries < 10 => { + tries += 1; + std::thread::sleep(Duration::from_millis(50)); + } + r => return r, + } + } +} + +fn client() -> Result { + builder().build().context("creating the HTTP client") +} + +/// 走环境变量里的代理(`HTTPS_PROXY`):服务器上出网常常只有这一条路。 +fn builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .user_agent(concat!("twcore/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(300)) + // Release 的下载地址会跳到 GitHub 的存储域名,得跟;但不跟到 http 上去 + .redirect(reqwest::redirect::Policy::custom(|a| { + if a.url().scheme() == "http" + && a.previous().last().is_some_and(|u| u.scheme() == "https") + { + a.error("refused to follow a redirect from https to http") + } else if a.previous().len() >= 10 { + a.error("too many redirects") + } else { + a.follow() + } + })) +} + +async fn get(client: &reqwest::Client, url: &str) -> Result { + let r = client + .get(url) + .send() + .await + .with_context(|| format!("requesting {url}"))?; + if !r.status().is_success() { + bail!("{url} answered {}", r.status()); + } + Ok(r) +} + +/// 最新的一版,或者指定的那一版。 +pub async fn release( + client: &reqwest::Client, + api: &str, + want: Option, +) -> Result { + let url = match want { + Some(v) => format!("{api}/repos/{REPO}/releases/tags/v{v}"), + None => format!("{api}/repos/{REPO}/releases/latest"), + }; + let r = client + .get(&url) + .header("accept", "application/vnd.github+json") + .send() + .await + .with_context(|| format!("asking GitHub for the release ({url})"))?; + match r.status() { + s if s.is_success() => Ok(r.json().await.context("reading the release")?), + reqwest::StatusCode::NOT_FOUND => match want { + Some(v) => bail!("there is no release v{v}"), + None => bail!("there is no release yet"), + }, + s => bail!("GitHub answered {s} for {url}"), + } +} + +/// 做什么,由版本比较决定。 +#[derive(Debug, PartialEq, Eq)] +pub enum Plan { + /// 已经是它了 + Same, + /// 没指定版本,而这一版比最新的还新(自己编的、或者还没发出去的) + Ahead, + Install, +} + +pub fn plan(current: Version, target: Version, pinned: bool) -> Plan { + match current.cmp(&target) { + std::cmp::Ordering::Equal => Plan::Same, + // 指定了版本就照装,哪怕是降级 —— 服务器要和桌面应用对上版本,那一版 + // 可能正好比服务器上的旧 + std::cmp::Ordering::Greater if !pinned => Plan::Ahead, + _ => Plan::Install, + } +} + +/// 装好之后换了什么。给调用方决定要不要重启。 +#[derive(Debug)] +pub struct Installed { + pub to: Version, +} + +/// 一次升级,除了「说给人听」和「重启服务」之外的全部。拆出来是为了测试能 +/// 指一个假的 API 和一个假的可执行文件。 +pub async fn upgrade( + client: &reqwest::Client, + api: &str, + exe: &Path, + current: Version, + target: &str, + opts: &Opts, + run_check: bool, +) -> Result> { + let pinned = opts + .version + .as_deref() + .map(str::parse::) + .transpose()?; + let rel = release(client, api, pinned).await?; + let to = rel.version()?; + match plan(current, to, pinned.is_some()) { + Plan::Same => { + println!( + "twcore {current} is installed, and it is {}", + if pinned.is_some() { + "the version asked for" + } else { + "the latest release" + } + ); + return Ok(None); + } + Plan::Ahead => { + println!("twcore {current} is newer than the latest release, {to}; nothing to do"); + return Ok(None); + } + Plan::Install => {} + } + let asset = asset_name(target); + let (bin_url, sha_url) = rel.pick(&asset)?; + if opts.check { + let how = match pinned { + Some(v) => format!("twcore upgrade --version {v}"), + None => "twcore upgrade".to_string(), + }; + println!("twcore {current} is installed; {to} is available. To install it: {how}"); + return Ok(None); + } + check_writable(exe)?; + println!("downloading {asset} {to}"); + let sha = get(client, sha_url).await?.text().await?; + let bytes = get(client, bin_url).await?.bytes().await?; + verify(&bytes, &sha)?; + replace( + exe, + &bytes, + |p| if run_check { runs_as(p, to) } else { Ok(()) }, + )?; + println!("replaced {}: {current} → {to}", exe.display()); + Ok(Some(Installed { to })) +} + +/// systemd 在不在管这个服务。 +fn service_active() -> Option { + let st = std::process::Command::new("systemctl") + .args(["is-active", "--quiet", UNIT]) + .status() + .ok()?; + Some(st.success()) +} + +pub fn run(opts: Opts) -> Result<()> { + let exe = std::env::current_exe().context("finding this executable")?; + // 符号链接要落到真正的文件上:改名盖的是链接指向的那个 + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + if in_app_bundle(&exe) { + bail!( + "{} is the copy inside the ThinkWatch Lite app, and the app updates it itself. \ + `twcore upgrade` is for a twcore installed on its own, such as on a server", + exe.display() + ); + } + let target = target_for(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| { + anyhow!( + "no twcore is published for {}-{}", + std::env::consts::OS, + std::env::consts::ARCH + ) + })?; + let current: Version = env!("CARGO_PKG_VERSION").parse()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let done = rt.block_on(async { + let c = client()?; + upgrade(&c, API, &exe, current, target, &opts, true).await + })?; + let Some(done) = done else { + return Ok(()); + }; + // 重启是人的决定:换二进制不打断在途请求,重启会 + match ( + cfg!(target_os = "linux").then(service_active).flatten(), + opts.restart, + ) { + (Some(true), true) => { + let st = std::process::Command::new("systemctl") + .args(["restart", UNIT]) + .status() + .context("running systemctl restart")?; + if !st.success() { + bail!( + "systemctl restart {UNIT} failed; the new binary is in place, restart it by hand" + ); + } + println!("restarted {UNIT}.service; it now runs {}", done.to); + } + (Some(true), false) => { + println!("{UNIT}.service is still running the previous version. To switch:"); + println!(" sudo systemctl restart {UNIT}"); + } + (_, true) => println!( + "no running {UNIT}.service was found to restart; restart twcore wherever it runs" + ), + (_, false) => { + println!("a twcore that is running keeps the previous version until it is restarted") + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + s.parse().unwrap() + } + + #[test] + fn versions_compare_as_numbers_not_text() { + assert!(v("0.10.0") > v("0.9.9")); + assert!(v("1.0.0") > v("0.99.99")); + assert_eq!(v("v0.47.0"), v("0.47.0")); + for bad in ["0.47", "0.47.0.1", "x.1.2", "0.47.0-rc1", ""] { + assert!(bad.parse::().is_err(), "{bad}"); + } + } + + #[test] + fn a_pinned_version_installs_even_when_it_is_older() { + // 服务器要和桌面应用对上版本,那一版可能比服务器上的旧 + assert_eq!(plan(v("0.48.0"), v("0.47.2"), true), Plan::Install); + assert_eq!(plan(v("0.48.0"), v("0.47.2"), false), Plan::Ahead); + assert_eq!(plan(v("0.47.2"), v("0.48.0"), false), Plan::Install); + assert_eq!(plan(v("0.48.0"), v("0.48.0"), true), Plan::Same); + } + + #[test] + fn every_published_platform_has_an_asset_and_others_have_none() { + assert_eq!( + target_for("linux", "x86_64").map(asset_name).as_deref(), + Some("twcore-x86_64-unknown-linux-gnu") + ); + assert_eq!( + target_for("linux", "aarch64").map(asset_name).as_deref(), + Some("twcore-aarch64-unknown-linux-gnu") + ); + assert_eq!( + target_for("macos", "aarch64").map(asset_name).as_deref(), + Some("twcore-aarch64-apple-darwin") + ); + assert_eq!( + target_for("windows", "x86_64").map(asset_name).as_deref(), + Some("twcore-x86_64-pc-windows-msvc.exe") + ); + // Intel Mac 没有构建:说没有,而不是装一个 arm64 的上去 + assert_eq!(target_for("macos", "x86_64"), None); + // 这台跑测试的机器本身得在表里 + assert!(target_for(std::env::consts::OS, std::env::consts::ARCH).is_some()); + } + + /// release.yml 发的文件名和这里拼的要一致 —— 两边各写一遍,对不上时 + /// `twcore upgrade` 找不到文件 + #[test] + fn the_release_workflow_publishes_what_upgrade_looks_for() { + let wf = include_str!("../../../.github/workflows/release.yml"); + for (os, arch) in [ + ("macos", "aarch64"), + ("linux", "x86_64"), + ("linux", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + let t = target_for(os, arch).unwrap(); + let name = asset_name(t); + let pattern = name.replace(t, "${{ matrix.target }}"); + assert!( + wf.contains(&format!("dist/{name}.sha256")) + || wf.contains(&format!("dist/{pattern}.sha256")), + "release.yml does not publish {name}.sha256" + ); + } + } + + fn release_with(names: &[&str]) -> Release { + Release { + tag_name: "v0.48.0".into(), + assets: names + .iter() + .map(|n| Asset { + name: n.to_string(), + browser_download_url: format!("https://example.com/{n}"), + }) + .collect(), + } + } + + #[test] + fn picking_needs_the_binary_and_its_checksum() { + let a = "twcore-x86_64-unknown-linux-gnu"; + let r = release_with(&[ + a, + "twcore-x86_64-unknown-linux-gnu.sha256", + "twcore-x86_64-unknown-linux-gnu.tar.gz", + ]); + let (b, s) = r.pick(a).unwrap(); + assert!(b.ends_with(a)); + assert!(s.ends_with(".sha256")); + let e = release_with(&[a]).pick(a).unwrap_err().to_string(); + assert!(e.contains("cannot be verified"), "{e}"); + let e = release_with(&[]).pick(a).unwrap_err().to_string(); + assert!(e.contains("no build"), "{e}"); + } + + #[test] + fn the_checksum_has_to_match() { + let body = b"twcore"; + let good: String = Sha256::digest(body) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + verify(body, &format!("{good} twcore-x86_64-unknown-linux-gnu\n")).unwrap(); + // 大写的十六进制也是同一个值 + verify(body, &good.to_uppercase()).unwrap(); + assert!(verify(b"twcorE", &good).is_err()); + assert!(verify(body, "not a checksum").is_err()); + assert!(verify(body, "").is_err()); + } + + #[test] + fn the_copy_inside_the_desktop_app_is_not_upgraded() { + for p in [ + "/Applications/ThinkWatch Lite.app/Contents/Resources/twcore", + "/tmp/.mount_ThinkWxyz/usr/lib/ThinkWatch Lite/twcore", + "/opt/ThinkWatch Lite/usr/lib/ThinkWatch Lite/twcore", + r"C:\Users\a\AppData\Local\ThinkWatch Lite\twcore.exe", + ] { + let p = PathBuf::from(p.replace('\\', std::path::MAIN_SEPARATOR_STR)); + assert!(in_app_bundle(&p), "{}", p.display()); + } + for p in ["/usr/local/bin/twcore", "/home/a/.local/bin/twcore"] { + assert!(!in_app_bundle(Path::new(p)), "{p}"); + } + } + + #[test] + fn replacing_is_all_or_nothing() { + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + + // 检查不过:旧的原样在,临时文件不留 + let e = replace(&exe, b"new", |_| bail!("does not run")).unwrap_err(); + assert!(e.to_string().contains("does not run")); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + + // 检查拿到的是写好的新文件,不是旧的 + replace(&exe, b"new", |p| { + assert_ne!(p, exe.as_path()); + assert_eq!(std::fs::read(p).unwrap(), b"new"); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&exe).unwrap(), b"new"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&exe).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755); + // 只剩它自己,没有临时文件 + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + } + } + + /// 一个进程在跑着旧文件的时候换掉它:它照常跑完,路径上已经是新的。 + #[cfg(unix)] + #[test] + fn a_running_binary_can_be_replaced_under_it() { + use std::io::BufRead; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + // 脚本由 sh 打开之后才算「在跑」:等它先说一句,再去换 + write_executable(&exe, b"#!/bin/sh\necho started\nsleep 1\necho old\n").unwrap(); + let spawn = || { + std::process::Command::new(&exe) + .stdout(std::process::Stdio::piped()) + .spawn() + }; + let mut child = (0..10) + .find_map(|_| match spawn() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(Duration::from_millis(50)); + None + } + r => Some(r.unwrap()), + }) + .unwrap(); + let mut lines = std::io::BufReader::new(child.stdout.take().unwrap()).lines(); + assert_eq!(lines.next().unwrap().unwrap(), "started"); + replace(&exe, b"#!/bin/sh\necho new\n", |_| Ok(())).unwrap(); + assert_eq!(lines.next().unwrap().unwrap(), "old"); + child.wait().unwrap(); + let now = run_fresh(&mut std::process::Command::new(&exe)).unwrap(); + assert_eq!(String::from_utf8_lossy(&now.stdout).trim(), "new"); + } + + // ── 对着一个假的 GitHub 跑一整遍 ───────────────────────── + + struct Fake { + base: String, + _task: tokio::task::JoinHandle<()>, + } + + /// 一个只认三条路径的 GitHub:最新 Release、按 tag 查的 Release、下载。 + async fn fake_github(tag: &'static str, binary: Vec, sha: String) -> Fake { + use axum::{Router, extract::State, routing::get}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let asset = asset_name(target_for(std::env::consts::OS, std::env::consts::ARCH).unwrap()); + let release = serde_json::json!({ + "tag_name": tag, + "assets": [ + { "name": asset, "browser_download_url": format!("{base}/dl/bin") }, + { "name": format!("{asset}.sha256"), "browser_download_url": format!("{base}/dl/sha") }, + ], + }); + #[derive(Clone)] + struct S { + release: serde_json::Value, + tag: &'static str, + binary: Vec, + sha: String, + } + let app = Router::new() + .route( + "/repos/ThinkWatchProject/ThinkWatch-Core/releases/latest", + get(|State(s): State| async move { axum::Json(s.release) }), + ) + .route( + "/repos/ThinkWatchProject/ThinkWatch-Core/releases/tags/{tag}", + get( + |State(s): State, axum::extract::Path(t): axum::extract::Path| async move { + if t == s.tag { + Ok(axum::Json(s.release)) + } else { + Err(axum::http::StatusCode::NOT_FOUND) + } + }, + ), + ) + .route("/dl/bin", get(|State(s): State| async move { s.binary })) + .route("/dl/sha", get(|State(s): State| async move { s.sha })) + .with_state(S { + release, + tag, + binary, + sha, + }); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Fake { base, _task: task } + } + + fn sha_of(b: &[u8]) -> String { + let h: String = Sha256::digest(b) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("{h} twcore\n") + } + + fn opts(check: bool, version: Option<&str>) -> Opts { + Opts { + check, + restart: false, + version: version.map(str::to_string), + } + } + + fn target() -> &'static str { + target_for(std::env::consts::OS, std::env::consts::ARCH).unwrap() + } + + /// 假服务器在回环上,不能让开发机上的代理变量把请求带走 + fn client() -> reqwest::Client { + builder().no_proxy().build().unwrap() + } + + #[tokio::test] + async fn an_upgrade_downloads_verifies_and_replaces() { + // 新的「二进制」是一段脚本,`--version` 报 0.48.0 —— 于是「跑一次看它报 + // 什么」这一步也是真跑的 + let new = b"#!/bin/sh\necho twcore 0.48.0\n".to_vec(); + let fake = fake_github("v0.48.0", new.clone(), sha_of(&new)).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let c = client(); + + // 只看不动 + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(true, None), + cfg!(unix), + ) + .await + .unwrap(); + assert!(r.is_none()); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + cfg!(unix), + ) + .await + .unwrap() + .expect("it installs"); + assert_eq!(r.to, v("0.48.0")); + assert_eq!(std::fs::read(&exe).unwrap(), new); + + // 已经是最新的:什么都不做 + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.48.0"), + target(), + &opts(false, None), + cfg!(unix), + ) + .await + .unwrap(); + assert!(r.is_none()); + } + + #[tokio::test] + async fn a_download_that_does_not_match_its_checksum_changes_nothing() { + let fake = fake_github("v0.48.0", b"tampered".to_vec(), sha_of(b"original")).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + false, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("SHA-256"), "{e}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + } + + /// 下载下来的东西报的不是要装的那一版(发错了文件):不装。 + #[cfg(unix)] + #[tokio::test] + async fn a_binary_that_reports_another_version_is_not_installed() { + let wrong = b"#!/bin/sh\necho twcore 0.46.0\n".to_vec(); + let fake = fake_github("v0.48.0", wrong.clone(), sha_of(&wrong)).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + true, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("0.46.0"), "{e}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + } + + #[tokio::test] + async fn a_pinned_version_that_does_not_exist_says_so() { + let fake = fake_github("v0.48.0", Vec::new(), String::new()).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, Some("0.9.9")), + false, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("no release v0.9.9"), "{e}"); + } +} From d06c9763d6d6a32dcffe96cd8dc11694b26fe545 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:32:40 +0800 Subject: [PATCH 2/5] feat(release): Linux tarballs, a systemd unit and a one-line install script Running core on a Linux server and managing it from the desktop app needs a way to put it there. The release already published bare Linux binaries (the desktop pipeline takes them); it now also publishes twcore-.tar.gz for x86_64 and aarch64, holding the binary with its executable bit, the systemd unit and the licence, each with a .sha256. The bare binaries and their names are unchanged. packaging/systemd/twcore.service runs core as a dedicated user with its data in /var/lib/thinkwatch (StateDirectory, 0700), reads ${VAR} values from /etc/thinkwatch/env, restarts on failure, and is hardened so it can write only its data directory (systemd-analyze security: 1.4). It keeps AF_NETLINK so a `bind` naming an interface still resolves. scripts/install.sh (POSIX sh) detects the architecture, downloads the latest or a pinned release, verifies the SHA-256, checks the binary runs here, installs it atomically into /usr/local/bin, creates the user, the data directory and the environment file, installs the unit, and runs `twcore init` when there is no configuration. It is safe to run again: configuration, environment and data are never touched, and it never starts or restarts the service itself. docs/server.md (and zh-CN) walks through install, init, the three config edits, systemd, `twcore control-key` and connecting the desktop app, plus upgrading and uninstalling. The unit and the script were run under systemd on Ubuntu 22.04 (aarch64): install, check, enable --now, live reload, reinstall while running, and `twcore upgrade --restart`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 37 +++++- docs/server.md | 206 +++++++++++++++++++++++++++++++ docs/server.zh-CN.md | 157 +++++++++++++++++++++++ packaging/systemd/twcore.service | 67 ++++++++++ scripts/install.sh | 206 +++++++++++++++++++++++++++++++ 5 files changed, 669 insertions(+), 4 deletions(-) create mode 100644 docs/server.md create mode 100644 docs/server.zh-CN.md create mode 100644 packaging/systemd/twcore.service create mode 100755 scripts/install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79be20ee..b222bbed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,15 +252,42 @@ jobs: fi "$BIN" --help > /dev/null + # **两种形态。**裸二进制给桌面版的流水线和 `twcore upgrade`(它们只要 + # 那一个文件);压缩包给服务器上的首次安装(`scripts/install.sh`): + # 里面还有 systemd unit,装的 unit 和装的二进制出自同一个 commit。 + # 压缩包还保住了可执行位 —— 裸文件下载下来是 0644。 - name: Package + env: + TARGET: ${{ matrix.target }} run: | set -euo pipefail - mkdir -p dist - cp target/${{ matrix.target }}/release/twcore dist/twcore-${{ matrix.target }} + # 压缩包里的目录和裸二进制同名,所以在 dist 外面搭 + STAGE="$RUNNER_TEMP/stage/twcore-$TARGET" + mkdir -p dist "$STAGE" + cp "target/$TARGET/release/twcore" "dist/twcore-$TARGET" + install -m 0755 "target/$TARGET/release/twcore" "$STAGE/twcore" + install -m 0644 packaging/systemd/twcore.service LICENSE "$STAGE/" + tar -czf "dist/twcore-$TARGET.tar.gz" --owner=0 --group=0 -C "$RUNNER_TEMP/stage" "twcore-$TARGET" cd dist # 和另外两个平台同一种校验文件:` <文件名>` - sha256sum twcore-${{ matrix.target }} > twcore-${{ matrix.target }}.sha256 - cat twcore-${{ matrix.target }}.sha256 + sha256sum "twcore-$TARGET" > "twcore-$TARGET.sha256" + sha256sum "twcore-$TARGET.tar.gz" > "twcore-$TARGET.tar.gz.sha256" + cat ./*.sha256 + tar -tzvf "twcore-$TARGET.tar.gz" + + # 装脚本认的就是这个布局,这里照着它的步骤解一遍、跑一遍 —— 布局改了 + # 而脚本没跟上,在这里就挂,而不是在用户的服务器上。 + - name: The archive is what install.sh expects + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + T=$(mktemp -d) + (cd dist && sha256sum -c "twcore-$TARGET.tar.gz.sha256") + tar -xzf "dist/twcore-$TARGET.tar.gz" -C "$T" + test -x "$T/twcore-$TARGET/twcore" + test -f "$T/twcore-$TARGET/twcore.service" + "$T/twcore-$TARGET/twcore" --version - uses: softprops/action-gh-release@v2 # 排练不发布。 @@ -269,4 +296,6 @@ jobs: files: | dist/twcore-${{ matrix.target }} dist/twcore-${{ matrix.target }}.sha256 + dist/twcore-${{ matrix.target }}.tar.gz + dist/twcore-${{ matrix.target }}.tar.gz.sha256 generate_release_notes: true diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 00000000..d6174ac1 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,206 @@ +# Running core on a server + +[中文](server.zh-CN.md) + +ThinkWatch Core runs without a desktop: on a Linux machine it is started by +systemd from its configuration file, and the ThinkWatch Lite app on a Mac +connects to it over the network to show traffic and change settings. Clients +anywhere on the network send their requests to the server's gateway. + +This page covers installing, configuring, starting, connecting and +upgrading. Every field mentioned is described in the +[configuration reference](config.md). + +## Requirements + +- Linux on x86_64 or aarch64, with glibc 2.35 or newer (Ubuntu 22.04, + Debian 12, or later). +- systemd. +- The server's core version has to match the desktop app's. The app checks + this when it connects and shows both versions if they differ. + +## 1. Install + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +To install a particular version, the one your desktop app expects: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +The script: + +1. downloads `twcore--unknown-linux-gnu.tar.gz` from the GitHub + release and checks it against the release's SHA-256 sum; +2. installs the binary as `/usr/local/bin/twcore`; +3. creates the system user `thinkwatch` and the data directory + `/var/lib/thinkwatch` (mode `0700`); +4. installs `/etc/systemd/system/twcore.service` and an empty + `/etc/thinkwatch/env`; +5. runs `twcore init` as `thinkwatch` if there is no configuration yet; +6. prints the next steps. It does not start the service. + +Running it again is safe: it replaces the binary and the unit, and leaves +the configuration, the environment file and the data alone. For later +upgrades, `twcore upgrade` is the shorter way (see below). + +To install by hand instead, download the tarball and its `.sha256` from the +[releases page](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases), +check it with `sha256sum -c`, and follow the steps above; the unit file is +in the tarball and in [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service). + +Every `twcore` command that reads the configuration has to run as the +service user with the service's data directory. The examples below spell +that out; a shell alias saves typing: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. Configure + +Open `/var/lib/thinkwatch/config.yaml` as root (`sudoedit` works) and change +three things: + +1. **Let clients on the network reach the gateway**: `listen.gateway.bind: + all`, and list their networks in `listen.gateway.allow_from`. +2. **Open the remote control port**: `listen.control.remote.enabled: true`, + and list the networks the desktop app connects from in its + `allow_from`. `twcore init` writes this section with a random port and + `enabled: false`; if your file has no `remote` section, add one with any + free port. +3. **Add at least one upstream** under `providers`, or add it later from the + desktop app. + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # written by twcore init; leave it as it is + remote: + enabled: true + bind: all + port: 41327 # written by twcore init + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # written by twcore init +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +Check the result without starting anything: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### Secrets in the environment + +`${NAME}` in the configuration reads the environment of the core process. +Under systemd that is `/etc/thinkwatch/env`, one `NAME=value` per line: + +```sh +sudoedit /etc/thinkwatch/env # created by the installer: root:thinkwatch, 0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +The file is read when the service starts: after changing it, restart the +service. Proxy variables here are what `proxy: system` uses. + +### Network + +Neither port has TLS. The control port is encrypted and authenticated by +its handshake; the gateway port carries requests in plain HTTP, like any +local model server. Keep both reachable only from networks you trust: set +`allow_from`, and open the two ports in the server's firewall to those +networks only. For access from outside, use a VPN or an SSH tunnel rather +than exposing the ports. + +## 3. Start + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +The unit runs core as `thinkwatch`, restarts it if it fails, and keeps it +out of the rest of the file system: it can write only its data directory. + +Configuration changes need no restart. Core reloads the file within a +second of a save, from an editor, `twcore config`, or the desktop app; a +change that does not validate is refused and the previous configuration +keeps serving. + +## 4. Connect the desktop app + +Show the control key on the server: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +In the desktop app, open **Settings → Connections → Add remote connection** +and enter: + +- **Address**: the server's host name or IP address; +- **Control port**: `listen.control.remote.port`; +- **Key**: the 64 characters `twcore control-key` printed. + +The app tests the connection before saving and says what is wrong if it +fails: no answer (address, port, firewall, `enabled`), connection closed +(this Mac's address is probably not in `allow_from`), wrong key, or +different versions. + +The key is kept in the Mac's keychain. To replace it, run +`twcore control-key --rotate` on the server; connected apps then have to be +given the new key. + +### Point clients at the server + +Clients use the server's gateway, `http://:8788`, with a gateway key +from `clients`. The desktop app can point the clients on the Mac at the +server (Clients page); on other machines, configure them by hand. + +## Upgrading + +```sh +sudo twcore upgrade --check # compare with the latest release, change nothing +sudo twcore upgrade --restart # install the latest release and restart the service +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` downloads the release for this machine, checks its +SHA-256 sum, and replaces `/usr/local/bin/twcore` in one step, so a failed +download never leaves a broken binary. The configuration and the data are +not touched. Without `--restart` it prints the command to restart the +service; the running process keeps the old version until then. + +Upgrade the server and the desktop app together: the app refuses to connect +to a core of another version and shows the command above with the version +it needs. + +## Uninstalling + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# The configuration, keys and request history: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/docs/server.zh-CN.md b/docs/server.zh-CN.md new file mode 100644 index 00000000..05432b5d --- /dev/null +++ b/docs/server.zh-CN.md @@ -0,0 +1,157 @@ +# 在服务器上运行 core + +[English](server.md) + +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 + +本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 + +## 要求 + +- x86_64 或 aarch64 的 Linux,glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 +- systemd。 +- 服务器上的 core 版本须与桌面应用一致。应用在连接时核对版本,不一致时显示双方的版本号。 + +## 1. 安装 + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +安装指定版本(即桌面应用要求的版本): + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +安装脚本依次: + +1. 从 GitHub Release 下载 `twcore-<架构>-unknown-linux-gnu.tar.gz`,并用 Release 中的 SHA-256 校验; +2. 把程序安装为 `/usr/local/bin/twcore`; +3. 创建系统用户 `thinkwatch` 和数据目录 `/var/lib/thinkwatch`(权限 `0700`); +4. 安装 `/etc/systemd/system/twcore.service`,以及空的 `/etc/thinkwatch/env`; +5. 还没有配置时,以 `thinkwatch` 身份执行 `twcore init`; +6. 打印后续步骤。脚本不启动服务。 + +脚本可以重复执行:它替换程序和 unit 文件,不动配置、环境变量文件和数据。之后升级用 `twcore upgrade` 更简便(见下文)。 + +也可以手动安装:从 [Releases 页面](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases)下载压缩包及其 `.sha256`,用 `sha256sum -c` 校验,再按上述步骤操作;unit 文件在压缩包中,也在 [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service)。 + +读取配置的 `twcore` 命令,都要以服务用户的身份、带上服务的数据目录执行。下文的示例都写全了;可以用一个 shell 别名简化: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. 配置 + +以 root 身份打开 `/var/lib/thinkwatch/config.yaml`(可用 `sudoedit`),修改三处: + +1. **让网络中的客户端能访问网关**:`listen.gateway.bind: all`,并在 `listen.gateway.allow_from` 中列出客户端所在的网段。 +2. **打开远程控制端口**:`listen.control.remote.enabled: true`,并在其 `allow_from` 中列出桌面应用所在的网段。`twcore init` 生成的配置带有这一节,端口随机,`enabled: false`;文件中没有 `remote` 这一节时,自行添加,端口任选一个空闲的。 +3. **在 `providers` 下添加至少一个上游**,也可以之后在桌面应用中添加。 + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # twcore init 生成,保持原样 + remote: + enabled: true + bind: all + port: 41327 # twcore init 生成 + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # twcore init 生成 +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +只校验、不启动: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### 用环境变量存放密钥 + +配置中的 `${NAME}` 读取 core 进程的环境变量。在 systemd 下,环境变量来自 `/etc/thinkwatch/env`,每行一个 `NAME=value`: + +```sh +sudoedit /etc/thinkwatch/env # 由安装脚本创建:root:thinkwatch,0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +这个文件在服务启动时读取,修改后需要重启服务。其中的代理变量就是 `proxy: system` 使用的代理。 + +### 网络 + +两个端口都没有 TLS。控制端口由握手完成加密和鉴权;网关端口以明文 HTTP 传输请求,和本地模型服务一样。两个端口都只应对可信的网络开放:设置 `allow_from`,并在服务器防火墙中只对这些网段开放这两个端口。需要从外部访问时,使用 VPN 或 SSH 隧道,不要直接暴露端口。 + +## 3. 启动 + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +unit 以 `thinkwatch` 身份运行 core,失败后自动重启,并把它与文件系统的其余部分隔开:它只能写自己的数据目录。 + +修改配置不需要重启。无论通过编辑器、`twcore config` 还是桌面应用保存,core 都会在一秒内重新加载;未通过校验的改动被拒绝,原有配置继续服务。 + +## 4. 连接桌面应用 + +在服务器上查看控制密钥: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +在桌面应用中打开 **设置 → 连接 → 添加远程连接**,填写: + +- **地址**:服务器的主机名或 IP 地址; +- **控制端口**:`listen.control.remote.port` 的值; +- **密钥**:`twcore control-key` 输出的 64 个字符。 + +应用在保存前先试连,失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致。 + +密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`,之后已连接的应用需要填入新密钥。 + +### 让客户端指向服务器 + +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 + +## 升级 + +```sh +sudo twcore upgrade --check # 与最新版本比较,不做任何改动 +sudo twcore upgrade --restart # 安装最新版本并重启服务 +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` 下载适合本机的版本,校验 SHA-256,一步替换 `/usr/local/bin/twcore`,下载失败也不会留下损坏的程序。配置和数据不受影响。不带 `--restart` 时只打印重启服务的命令;在重启之前,运行中的进程仍是旧版本。 + +服务器和桌面应用要一起升级:版本不一致时应用拒绝连接,并显示上面的命令及所需的版本。 + +## 卸载 + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# 配置、密钥和请求历史: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/packaging/systemd/twcore.service b/packaging/systemd/twcore.service new file mode 100644 index 00000000..76c00feb --- /dev/null +++ b/packaging/systemd/twcore.service @@ -0,0 +1,67 @@ +# ThinkWatch Core as a system service. +# +# Installed by scripts/install.sh as /etc/systemd/system/twcore.service. +# Setup and the configuration it reads: docs/server.md. +# +# Core keeps everything in its data directory, /var/lib/thinkwatch +# (THINKWATCH_HOME): config.yaml, the request database, the history and the +# local control socket. It is the only place the service can write. + +[Unit] +Description=ThinkWatch Core (AI gateway) +Documentation=https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=thinkwatch +Group=thinkwatch +Environment=THINKWATCH_HOME=/var/lib/thinkwatch +# ${VAR} in config.yaml reads this environment: upstream keys, proxy +# passwords, HTTPS_PROXY for `proxy: system`. Optional (the leading -). +EnvironmentFile=-/etc/thinkwatch/env +ExecStart=/usr/local/bin/twcore serve +# Core reloads config.yaml on its own; a restart is needed only for a new +# binary or a changed environment file. +Restart=on-failure +RestartSec=2s +# SIGTERM lets requests in flight finish and the database close cleanly. +KillSignal=SIGTERM +TimeoutStopSec=30s + +# Creates /var/lib/thinkwatch owned by the service user, private to it. +StateDirectory=thinkwatch +StateDirectoryMode=0700 +UMask=0077 + +# Hardening. Core needs the network, its data directory, and nothing else. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +RestrictSUIDSGID=true +RestrictRealtime=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged +# AF_UNIX for the local control socket, AF_NETLINK to look up an +# interface named in `bind`. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +# Ports above 1024 need no capability. To listen on a lower one, set both +# AmbientCapabilities= and CapabilityBoundingSet= to CAP_NET_BIND_SERVICE. +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..b2631adc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,206 @@ +#!/bin/sh +# Install ThinkWatch Core on a Linux server, as a systemd service. +# +# curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +# ... | sudo sh -s -- --version 0.47.0 +# +# What it does, and the rest of the setup: docs/server.md. +# +# Running it again is safe. The binary and the unit are replaced; the +# configuration, /etc/thinkwatch/env and the data are never touched. The +# service is not started or restarted: that is left to the person running it. +# +# Options: +# --version X.Y.Z install this release instead of the latest +# --archive FILE install from a downloaded twcore-.tar.gz; its +# FILE.sha256 is checked when it is next to it +# +# POSIX sh on purpose: it runs before anything else is installed. + +set -eu + +REPO="ThinkWatchProject/ThinkWatch-Core" +BIN_DIR="/usr/local/bin" +USER_NAME="thinkwatch" +DATA_DIR="/var/lib/thinkwatch" +ETC_DIR="/etc/thinkwatch" +UNIT="/etc/systemd/system/twcore.service" + +say() { printf '%s\n' "$*"; } +die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } + +VERSION="" +ARCHIVE="" +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || die "--version needs a value"; VERSION="${2#v}"; shift 2 ;; + --version=*) VERSION="${1#--version=}"; VERSION="${VERSION#v}"; shift ;; + --archive) [ $# -ge 2 ] || die "--archive needs a file"; ARCHIVE="$2"; shift 2 ;; + --archive=*) ARCHIVE="${1#--archive=}"; shift ;; + -h|--help) + say "usage: install.sh [--version X.Y.Z] [--archive twcore-.tar.gz]" + say "Installs twcore into $BIN_DIR and a systemd unit. Guide: docs/server.md" + exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +# ── Where we are ────────────────────────────────────────────────────── + +[ "$(uname -s)" = Linux ] || die "this script installs the Linux server build. On macOS and Windows, core comes with the desktop app." +[ "$(id -u)" -eq 0 ] || die "run it as root (with sudo): it installs into $BIN_DIR and creates a system user." + +case "$(uname -m)" in + x86_64|amd64) TARGET="x86_64-unknown-linux-gnu" ;; + aarch64|arm64) TARGET="aarch64-unknown-linux-gnu" ;; + *) die "there is no build for $(uname -m); builds exist for x86_64 and aarch64." ;; +esac +ASSET="twcore-$TARGET.tar.gz" + +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + die "neither sha256sum nor shasum is installed, so the download cannot be verified." +fi + +fetch() { # URL FILE + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" + else + die "neither curl nor wget is installed." + fi +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT INT TERM + +# ── Get the archive and check it ────────────────────────────────────── + +if [ -n "$ARCHIVE" ]; then + [ -f "$ARCHIVE" ] || die "$ARCHIVE does not exist." + cp "$ARCHIVE" "$TMP/$ASSET" + if [ -f "$ARCHIVE.sha256" ]; then + cp "$ARCHIVE.sha256" "$TMP/$ASSET.sha256" + else + say "note: no $ARCHIVE.sha256 next to the archive; it is installed unverified." + fi +else + if [ -n "$VERSION" ]; then + BASE="https://github.com/$REPO/releases/download/v$VERSION" + else + BASE="https://github.com/$REPO/releases/latest/download" + fi + say "downloading $ASSET (${VERSION:-latest release})" + fetch "$BASE/$ASSET" "$TMP/$ASSET" \ + || die "could not download $BASE/$ASSET. Check the version, and that this machine reaches github.com." + fetch "$BASE/$ASSET.sha256" "$TMP/$ASSET.sha256" \ + || die "could not download $BASE/$ASSET.sha256." +fi + +if [ -f "$TMP/$ASSET.sha256" ]; then + WANT=$(cut -d' ' -f1 "$TMP/$ASSET.sha256") + GOT=$(sha256 "$TMP/$ASSET") + [ "$WANT" = "$GOT" ] || die "the SHA-256 of $ASSET is $GOT, and the release says $WANT. Nothing was installed." + say "SHA-256 checked: $GOT" +fi + +tar -xzf "$TMP/$ASSET" -C "$TMP" || die "$ASSET could not be unpacked." +SRC="$TMP/twcore-$TARGET" +[ -f "$SRC/twcore" ] || die "$ASSET does not contain twcore-$TARGET/twcore." + +# Does it run here? A glibc older than the build's shows up now rather than +# as a service that never starts. +NEW_VERSION=$("$SRC/twcore" --version 2>&1) \ + || die "the downloaded twcore does not run on this machine: $NEW_VERSION (it needs glibc 2.35 or newer)." + +# ── Install ─────────────────────────────────────────────────────────── + +# Next to the old one and renamed over it: a running service keeps its +# binary, and there is never a half-written file at the real path. +install -d -m 0755 "$BIN_DIR" +install -m 0755 "$SRC/twcore" "$BIN_DIR/.twcore.new" +mv -f "$BIN_DIR/.twcore.new" "$BIN_DIR/twcore" +say "installed $BIN_DIR/twcore ($NEW_VERSION)" + +if ! id "$USER_NAME" >/dev/null 2>&1; then + NOLOGIN=$(command -v nologin 2>/dev/null || echo /bin/false) + useradd --system --user-group --home-dir "$DATA_DIR" --no-create-home \ + --shell "$NOLOGIN" --comment "ThinkWatch Core" "$USER_NAME" \ + || die "could not create the user $USER_NAME." + say "created the system user $USER_NAME" +fi +install -d -m 0700 -o "$USER_NAME" -g "$USER_NAME" "$DATA_DIR" + +install -d -m 0755 "$ETC_DIR" +if [ ! -e "$ETC_DIR/env" ]; then + { + say "# Environment of the twcore service, one NAME=value per line." + say "# \${NAME} in $DATA_DIR/config.yaml reads it. Restart the service after a change." + say "#ANTHROPIC_API_KEY=sk-ant-..." + say "#HTTPS_PROXY=http://proxy.example.com:3128" + } > "$ETC_DIR/env" + chown "root:$USER_NAME" "$ETC_DIR/env" + chmod 0640 "$ETC_DIR/env" + say "created $ETC_DIR/env" +fi + +HAS_SYSTEMD=0 +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAS_SYSTEMD=1 +fi +if [ -f "$SRC/twcore.service" ]; then + install -d -m 0755 "$(dirname "$UNIT")" + install -m 0644 "$SRC/twcore.service" "$UNIT" + say "installed $UNIT" + if [ "$HAS_SYSTEMD" = 1 ]; then + systemctl daemon-reload + fi +fi + +as_service_user() { + if command -v runuser >/dev/null 2>&1; then + runuser -u "$USER_NAME" -- env THINKWATCH_HOME="$DATA_DIR" "$@" + else + # 单引号是有意的:$0、$@ 由 su 起的那个 shell 展开 + # shellcheck disable=SC2016 + su -s /bin/sh "$USER_NAME" -c 'THINKWATCH_HOME="$0" exec "$@"' "$DATA_DIR" "$@" + fi +} + +if [ ! -e "$DATA_DIR/config.yaml" ]; then + say "" + as_service_user "$BIN_DIR/twcore" init +fi + +# ── What next ───────────────────────────────────────────────────────── + +RUNNING=0 +if [ "$HAS_SYSTEMD" = 1 ] && systemctl is-active --quiet twcore 2>/dev/null; then + RUNNING=1 +fi + +say "" +if [ "$RUNNING" = 1 ]; then + say "The service is running the previous version. To switch to $NEW_VERSION:" + say " sudo systemctl restart twcore" +else + say "Next:" + say " 1. Edit $DATA_DIR/config.yaml (sudoedit works): set listen.gateway.bind to all," + say " enable listen.control.remote, and list your networks in both allow_from." + say " Keys for \${NAME} go in $ETC_DIR/env." + say " 2. Check it: sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore check" + if [ "$HAS_SYSTEMD" = 1 ]; then + say " 3. Start it: sudo systemctl enable --now twcore" + else + say " 3. This machine is not running systemd; start it with:" + say " sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore serve" + fi + say " 4. Get the key for the desktop app:" + say " sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore control-key" +fi +say "" +say "Guide: https://github.com/$REPO/blob/main/docs/server.md" From d0c5df7c28dc9e1fb984af421ddd67547b3b36f1 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:32:40 +0800 Subject: [PATCH 3/5] docs: a configuration reference whose field tables are generated and checked docs/config.md and docs/config.zh-CN.md describe every section and field of config.yaml, how a change takes effect (the reload stages, the rejected-config rule, history and rollback, the `twcore config` and `check` commands) and the environment variables core reads. The field tables and the built-in rule lists are rendered from crates/tw-config/tests/manual/schema.rs, and the `manual` test fails when the manual and the code disagree. The declaration is checked against the code, not trusted: - field names come from serde itself, through a probe deserializer that records what a derived Deserialize asks for, so a field added to a config type without a row in the manual fails with its name; - a declared default is written into a minimal section and must parse to the same thing as leaving the field out, and a required field must fail without it; - enum values are read from serde rather than copied; - the YAML examples in the manuals are parsed as configuration. `UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual` rewrites the generated blocks and leaves the prose alone. CONTRIBUTING explains the mechanism and lists the release assets. listen.control.key and listen.control.remote are documented ahead of the code (they land with the control key and remote access work). They are declared as pending: rendered in the manual, and the test fails as soon as the code reads those fields, so the declaration has to be switched to the real types and their defaults get checked. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 64 +- README.md | 5 + README.zh-CN.md | 3 + crates/tw-config/tests/manual.rs | 631 ++++++++++ crates/tw-config/tests/manual/schema.rs | 1431 +++++++++++++++++++++++ docs/config.md | 867 ++++++++++++++ docs/config.zh-CN.md | 753 ++++++++++++ 7 files changed, 3746 insertions(+), 8 deletions(-) create mode 100644 crates/tw-config/tests/manual.rs create mode 100644 crates/tw-config/tests/manual/schema.rs create mode 100644 docs/config.md create mode 100644 docs/config.zh-CN.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3dc49594..5012704c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,38 @@ clean the diff is: connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. +## The configuration reference + +`docs/config.md` and `docs/config.zh-CN.md` are written by hand, except the +field tables and the built-in rule lists: everything between +`` and `` is rendered from +`crates/tw-config/tests/manual/schema.rs`, and +`cargo test -p tw-config --test manual` fails when the two differ. + +That file declares every section of `config.yaml` against its Rust type, and +the test checks the declaration against the code rather than trusting it: + +- **Field names** come from serde itself (a probe deserializer records the + names a derived `Deserialize` asks for), so a field added to a config + type and not to the manual fails with the field's name. +- **Defaults are proven.** A declared default is written into a minimal + section and parsed; it has to mean the same as leaving the field out. A + field marked required has to fail without it. +- **Enum values** (`protocol`, `mode`, `type` …) are read from serde, not + copied. +- **Examples** in the manuals are parsed as configuration. + +When you change a config type, add or change its row (English and Chinese), +then regenerate: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` + +A section that is designed but not in the code yet is declared as +`Ty::Pending`: it is rendered, and the test fails as soon as the code starts +reading that field, so the declaration gets switched to the real type. + ## The price list Prices come in two layers. @@ -113,20 +145,36 @@ answered on the request itself. ## Cutting a release -`twcore` ships inside the desktop app's `.app`, so "which build is in -there" has to be a fact somebody can check rather than whatever sat in -a `target/` directory that afternoon. +`twcore` ships inside the desktop app, and on its own for servers, so +"which build is in there" has to be a fact somebody can check rather than +whatever sat in a `target/` directory that afternoon. 1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. -3. `release.yml` builds `twcore` for `aarch64-apple-darwin`, checks the +3. `release.yml` builds `twcore` for every target below, checks each binary actually runs and reports the version on the tag, and attaches - it to a GitHub Release with a `sha256`. + them to a GitHub Release, each with a `.sha256` (` `). + +| Target | Files | +|---|---| +| `aarch64-apple-darwin` | `twcore-aarch64-apple-darwin` | +| `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc` | `twcore-.exe` | +| `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` | `twcore-`, and `twcore-.tar.gz` holding the binary, `twcore.service` and `LICENSE` | + +The bare binaries are what the desktop app's pipeline bundles and what +`twcore upgrade` downloads. The Linux tarballs are what +`scripts/install.sh` installs on a server; they carry the systemd unit so +the unit and the binary come from the same commit. The file names are a +contract with both: `twcore upgrade` has a test that reads `release.yml`. + +To try a change to `release.yml` without publishing, run it by hand +(`workflow_dispatch`): it builds and checks everything and uploads nothing. The desktop app pins `tw-api` to the same tag and bundles the binary from that release. Those two have to come from one commit: the binary speaks a protocol, and the app compiles a mirror of it. -Apple Silicon only, deliberately. An Intel user downloading a file that -will not open is worse served than one who finds no download at all; -supporting them means a universal binary, which is its own decision. +On macOS, Apple Silicon only, deliberately. An Intel user downloading a +file that will not open is worse served than one who finds no download +at all; supporting them means a universal binary, which is its own +decision. diff --git a/README.md b/README.md index e00d1239..6d9eebcb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ cargo run -p twcore -- check # validate only, don't start cargo run -p twcore -- serve # start the gateway and control plane ``` +Every field of `config.yaml` is described in the +[configuration reference](docs/config.md). To run `twcore` on a Linux server +and manage it from the desktop app, see +[Running core on a server](docs/server.md). + ## What it does Point a client (Claude Code, Codex, and friends) at a local port, and: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6e381246..ad416734 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,6 +20,9 @@ cargo run -p twcore -- check # 只校验,不启动 cargo run -p twcore -- serve # 起网关和控制面 ``` +`config.yaml` 的每个字段见[配置手册](docs/config.zh-CN.md)。在 Linux 服务器上运行 +`twcore`、由桌面应用远程管理,见[在服务器上运行 core](docs/server.zh-CN.md)。 + ## 它做什么 把客户端(Claude Code、Codex 之类)指向本地的一个端口,然后: diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs new file mode 100644 index 00000000..7a30669b --- /dev/null +++ b/crates/tw-config/tests/manual.rs @@ -0,0 +1,631 @@ +//! 配置手册里的字段表是从这里生成的,手册和代码对不上就失败。 +//! +//! 手册是 `docs/config.md`(英文)和 `docs/config.zh-CN.md`(中文)。正文是人写的, +//! **字段表不是**:两个文件里每一处 +//! +//! ```text +//! +//! … +//! +//! ``` +//! +//! 之间的内容由 [`schema`] 里的声明渲染出来。声明和代码之间有三道核对: +//! +//! - **字段一个不多一个不少**:每一节的字段名问 serde 要([`fields`]),和声明的逐个比; +//! 代码里加了字段、手册没写,这里就挂,并且说出是哪一个。 +//! - **默认值是真的**:声明写「默认 8788」,就拿一份不写这个字段的和一份写了 8788 的 +//! 各解析一遍,两者得完全一样;「必填」的字段,删掉它就得解析失败。 +//! - **取值是生成的**:枚举的可选值问 serde 要,不在声明里抄。 +//! +//! 还没进代码的字段(远程控制那几项)声明成 [`Ty::Pending`]:照样出现在手册里, +//! 并且**一旦代码里有了这个字段,这里就挂**,逼着把它换成真正的核对。 +//! +//! 手册过期了:`UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual` 重写 +//! 两个文件里的生成段,正文不动。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde::de::{self, DeserializeOwned, Visitor}; + +const UPDATE: &str = "UPDATE_CONFIG_DOCS"; + +// ── 问 serde 要字段名 ───────────────────────────────────────── + +/// 一个什么都不给的反序列化器:derive 出来的 `Deserialize` 一开口就报出自己的 +/// 字段名(`deserialize_struct` 的 `fields`)或变体名(`deserialize_enum` 的 +/// `variants`),它记下来就停。**名字是 serde 真正认的那个**,改名、 +/// `rename_all` 都已经算进去了。 +struct Probe; + +#[derive(Debug)] +struct Found(Vec<&'static str>); + +impl std::fmt::Display for Found { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} +impl std::error::Error for Found {} +impl de::Error for Found { + fn custom(_: T) -> Self { + Found(Vec::new()) + } +} + +impl<'de> de::Deserializer<'de> for Probe { + type Error = Found; + fn deserialize_any>(self, _: V) -> Result { + Err(Found(Vec::new())) + } + fn deserialize_struct>( + self, + _: &'static str, + fields: &'static [&'static str], + _: V, + ) -> Result { + Err(Found(fields.to_vec())) + } + fn deserialize_enum>( + self, + _: &'static str, + variants: &'static [&'static str], + _: V, + ) -> Result { + Err(Found(variants.to_vec())) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map identifier ignored_any + } +} + +/// 一个 derive 出来的结构体的字段名,或者一个枚举的变体名。 +pub fn fields() -> Vec<&'static str> { + match T::deserialize(Probe) { + Err(Found(v)) if !v.is_empty() => v, + _ => panic!( + "{} does not derive Deserialize as a struct or an enum, so its fields cannot be listed", + std::any::type_name::() + ), + } +} + +/// 写了这个值和不写,解析出来是不是同一个东西。`minimal` 是这一节最少要写的那几个字段。 +pub fn same_as_omitted( + minimal: &str, + field: &str, + value: &str, +) -> Result { + let base: serde_yaml_ng::Value = serde_yaml_ng::from_str(minimal).map_err(|e| e.to_string())?; + let mut with = base.clone(); + let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(value).map_err(|e| e.to_string())?; + with.as_mapping_mut() + .ok_or("the minimal form is not a mapping")? + .insert(field.into(), v); + let a: T = serde_yaml_ng::from_value(base).map_err(|e| format!("without it: {e}"))?; + let b: T = serde_yaml_ng::from_value(with).map_err(|e| format!("with it: {e}"))?; + let a = serde_yaml_ng::to_value(a).map_err(|e| e.to_string())?; + let b = serde_yaml_ng::to_value(b).map_err(|e| e.to_string())?; + Ok(a == b) +} + +/// 去掉这个字段还解析得了吗。 +pub fn parses_without(minimal: &str, field: &str) -> Result { + let mut base: serde_yaml_ng::Value = + serde_yaml_ng::from_str(minimal).map_err(|e| e.to_string())?; + base.as_mapping_mut() + .ok_or("the minimal form is not a mapping")? + .remove(field); + Ok(serde_yaml_ng::from_value::(base).is_ok()) +} + +// ── 声明的形状 ──────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Lang { + En, + Zh, +} + +/// 一句话的两种语言。 +#[derive(Clone, Copy)] +pub struct T2 { + pub en: &'static str, + pub zh: &'static str, +} + +impl T2 { + fn get(&self, l: Lang) -> &'static str { + match l { + Lang::En => self.en, + Lang::Zh => self.zh, + } + } +} + +/// 字段的类型。**写成节名的那几种要有对应的一节**,测试核对。 +#[derive(Clone, Copy)] +pub enum Kind { + Str, + Int, + Num, + Bool, + Strs, + /// 可以带 `${VAR}` 的字符串 + Secret, + /// `loopback` / `all` / 网卡名 / 地址 + Bind, + /// 一个字符串或一组字符串 + OneOrMany, + /// `30s` / `5m` / `1h` + Duration, + /// 比较式:`">200k"` + Compare, + /// 请求头名 → 值 + Headers, + /// 可选值由枚举生成 + Enum(fn() -> Vec<&'static str>), + /// 键 → 枚举值 + EnumMap(T2, fn() -> Vec<&'static str>), + /// 一个对象,见那一节 + Obj(&'static str), + /// 一组对象,见那一节 + Objs(&'static str), + /// 键 → 对象,见那一节 + ObjMap(T2, &'static str), +} + +#[derive(Clone, Copy)] +pub enum Def { + /// 不写就解析失败 + Required, + /// 可选,不写就没有;意思在说明里 + Unset, + /// 一个 YAML 值。**核对过**:写它和不写解析出来一样 + Is(&'static str), + /// 核对不了的默认值(生成的、在别处算的),只能照说 + Said(T2), + /// 一个对象:默认值见那一节 + Section, +} + +pub struct Row { + pub name: &'static str, + pub kind: Kind, + pub def: Def, + pub doc: T2, +} + +/// 这一节对着哪个 Rust 类型核对。 +pub enum Ty { + Code { + fields: fn() -> Vec<&'static str>, + same: fn(&str, &str, &str) -> Result, + parses_without: fn(&str, &str) -> Result, + /// 这一节最少要写的字段,YAML 映射 + minimal: &'static str, + }, + /// 还没进代码。**进了代码这里就挂**:`probe` 是一份把这一节写成 `{}` 的配置, + /// 现在它必须因为「不认识这个字段」被拒 + Pending { probe: &'static str }, +} + +pub struct Section { + /// `listen.gateway`、`providers[]`、`pricing.sheets[].models.*` + pub path: &'static str, + pub ty: Ty, + pub rows: Vec, +} + +/// 核对一节要的那几个函数,按类型生成。 +macro_rules! checked { + ($t:ty, $minimal:expr) => { + $crate::Ty::Code { + fields: $crate::fields::<$t>, + same: $crate::same_as_omitted::<$t>, + parses_without: $crate::parses_without::<$t>, + minimal: $minimal, + } + }; +} + +// 声明在另一个文件里。放在宏后面,宏才看得见 +#[path = "manual/schema.rs"] +mod schema; + +// ── 渲染 ────────────────────────────────────────────────────── + +/// 节名在手册里的锚点。 +pub fn anchor(path: &str) -> String { + let mut out = String::from("cfg-"); + let mut dash = false; + for c in path.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + out.push(c); + dash = false; + } else if !dash { + out.push('-'); + dash = true; + } + } + out.trim_end_matches('-').to_string() +} + +fn values(v: &[&str]) -> String { + v.iter() + .map(|s| format!("`{s}`")) + .collect::>() + .join(" \\| ") +} + +fn link(path: &str) -> String { + format!("[`{path}`](#{})", anchor(path)) +} + +fn kind(k: &Kind, l: Lang) -> String { + let zh = l == Lang::Zh; + let pick = |en: &str, z: &str| if zh { z.to_string() } else { en.to_string() }; + match k { + Kind::Str => pick("string", "字符串"), + Kind::Int => pick("integer", "整数"), + Kind::Num => pick("number", "数字"), + Kind::Bool => pick("bool", "布尔"), + Kind::Strs => pick("list of strings", "字符串列表"), + Kind::Secret => pick("string, `${VAR}` allowed", "字符串,可写 `${VAR}`"), + Kind::Bind => pick( + "`loopback` \\| `all` \\| interface name \\| IP address", + "`loopback` \\| `all` \\| 网卡名 \\| IP 地址", + ), + Kind::OneOrMany => pick("string or list of strings", "字符串或字符串列表"), + Kind::Duration => pick("duration (`30s`, `5m`, `1h`)", "时长(`30s`、`5m`、`1h`)"), + Kind::Compare => pick( + "comparison (`>200k`, `<=4k`, `==3`)", + "比较式(`>200k`、`<=4k`、`==3`)", + ), + Kind::Headers => pick("map of header name → value", "请求头名 → 值的映射"), + Kind::Enum(f) => values(&f()), + Kind::EnumMap(key, f) => format!( + "{} {} → {}", + pick("map of", "映射:"), + key.get(l), + values(&f()) + ), + Kind::Obj(p) => format!("{} {}", pick("object,", "对象,见"), link(p)), + Kind::Objs(p) => format!("{} {}", pick("list of", "对象列表,见"), link(p)), + Kind::ObjMap(key, p) => { + format!("{} {} → {}", pick("map of", "映射:"), key.get(l), link(p)) + } + } +} + +fn default(d: &Def, l: Lang) -> String { + match d { + Def::Required => match l { + Lang::En => "**required**".into(), + Lang::Zh => "**必填**".into(), + }, + Def::Unset | Def::Section => "—".into(), + Def::Is(v) => format!("`{v}`"), + Def::Said(t) => t.get(l).into(), + } +} + +/// 表格里的一格:竖线要转义,换行不能有。 +fn cell(s: &str) -> String { + s.replace('\n', " ") +} + +pub fn render_table(s: &Section, l: Lang) -> String { + let mut out = format!("\n\n", anchor(s.path)); + out += match l { + Lang::En => "| Field | Type | Default | Description |\n", + Lang::Zh => "| 字段 | 类型 | 默认值 | 说明 |\n", + }; + out += "|---|---|---|---|\n"; + for r in &s.rows { + out += &format!( + "| `{}` | {} | {} | {} |\n", + r.name, + cell(&kind(&r.kind, l)), + cell(&default(&r.def, l)), + cell(r.doc.get(l)), + ); + } + out +} + +// ── 核对 ────────────────────────────────────────────────────── + +fn check_section(s: &Section, all: &[Section], errs: &mut Vec) { + for r in &s.rows { + match r.kind { + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => { + if !all.iter().any(|x| x.path == p) { + errs.push(format!( + "{}.{} points at section `{p}`, which is not declared", + s.path, r.name + )); + } + if !matches!(r.def, Def::Section | Def::Is(_) | Def::Unset) { + errs.push(format!( + "{}.{} is an object; its default is the section's own", + s.path, r.name + )); + } + } + _ => { + if matches!(r.def, Def::Section) { + errs.push(format!("{}.{} is not an object", s.path, r.name)); + } + } + } + } + match &s.ty { + Ty::Code { + fields, + same, + parses_without, + minimal, + } => { + let code: BTreeSet<&str> = fields().into_iter().collect(); + let declared: BTreeSet<&str> = s.rows.iter().map(|r| r.name).collect(); + for f in code.difference(&declared) { + errs.push(format!( + "`{}.{f}` is in the code but not in the manual: add a row for it in \ + crates/tw-config/tests/manual/schema.rs, then regenerate with {UPDATE}=1", + s.path + )); + } + for f in declared.difference(&code) { + errs.push(format!( + "`{}.{f}` is in the manual but not in the code: remove its row from \ + crates/tw-config/tests/manual/schema.rs", + s.path + )); + } + for r in &s.rows { + if !code.contains(r.name) { + continue; + } + match r.def { + Def::Is(v) => match same(minimal, r.name, v) { + Ok(true) => {} + Ok(false) => errs.push(format!( + "`{}.{}`: the manual says the default is `{v}`, and writing that \ + changes what the configuration means, so it is not the default", + s.path, r.name + )), + Err(e) => errs.push(format!( + "`{}.{}`: the default `{v}` does not parse: {e}", + s.path, r.name + )), + }, + Def::Required => match parses_without(minimal, r.name) { + Ok(false) => {} + Ok(true) => errs.push(format!( + "`{}.{}` is marked required, and the section parses without it", + s.path, r.name + )), + Err(e) => errs.push(format!("`{}`: {e}", s.path)), + }, + _ => {} + } + } + // 最少的写法本身要能解析,而且只写了必填的 + let required: BTreeSet<&str> = s + .rows + .iter() + .filter(|r| matches!(r.def, Def::Required)) + .map(|r| r.name) + .collect(); + match serde_yaml_ng::from_str::(minimal) { + Ok(m) => { + let written: BTreeSet<&str> = m.keys().filter_map(|k| k.as_str()).collect(); + if written != required { + errs.push(format!( + "`{}`: the minimal form writes {written:?}, and the required fields \ + are {required:?}", + s.path + )); + } + } + Err(e) => errs.push(format!("`{}`: the minimal form: {e}", s.path)), + } + } + Ty::Pending { probe } => { + let segs: Vec<&str> = s + .path + .split('.') + .map(|p| p.trim_end_matches("[]")) + .collect(); + match serde_yaml_ng::from_str::(probe) { + Err(e) + if segs + .iter() + .any(|seg| e.to_string().contains(&format!("unknown field `{seg}`"))) => {} + other => errs.push(format!( + "`{}` is declared as not yet in the code, and the code now reads it ({}). \ + Replace `Ty::Pending` with checked!(TheType, minimal) in \ + crates/tw-config/tests/manual/schema.rs so the manual is checked against it", + s.path, + match other { + Ok(_) => "the probe parses".to_string(), + Err(e) => e.to_string(), + } + )), + } + } + } +} + +#[test] +fn every_section_matches_the_code() { + let all = schema::sections(); + let mut errs = Vec::new(); + let mut seen = BTreeSet::new(); + for s in &all { + if !seen.insert(s.path) { + errs.push(format!("section `{}` is declared twice", s.path)); + } + check_section(s, &all, &mut errs); + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} + +/// 拿 [`Probe`] 问一个手写 `Deserialize` 的类型会得到什么 —— 它说不出字段, +/// 所以 [`fields`] 必须报错而不是返回空。 +#[test] +fn the_probe_refuses_types_it_cannot_list() { + let r = std::panic::catch_unwind(fields::); + assert!(r.is_err()); + assert_eq!( + fields::(), + ["socks5", "socks5h", "http", "https"] + ); +} + +// ── 手册文件 ────────────────────────────────────────────────── + +fn workspace() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +const OPEN: &str = ""; + +/// 一个生成段该是什么。`what` 是标记里写的那句:`table listen.gateway`、`rules redact` +fn generate(what: &str, l: Lang, all: &[Section]) -> Result { + if let Some(path) = what.strip_prefix("table ") { + let s = all + .iter() + .find(|s| s.path == path) + .ok_or_else(|| format!("no section `{path}` is declared"))?; + return Ok(render_table(s, l)); + } + if let Some(kind) = what.strip_prefix("rules ") { + return schema::rules(kind, l).ok_or_else(|| format!("no rule list `{kind}`")); + } + Err(format!("`{what}` is not something that can be generated")) +} + +/// 把一份手册里的生成段全部换成该有的样子,顺便记下写了哪些。 +fn regenerate(text: &str, l: Lang, all: &[Section]) -> Result<(String, Vec), String> { + let mut out = String::new(); + let mut rest = text; + let mut used = Vec::new(); + while let Some(i) = rest.find(OPEN) { + let after = &rest[i + OPEN.len()..]; + let end = after + .find("-->") + .ok_or("a generated marker is not closed")?; + let what = after[..end].trim().to_string(); + let body_start = i + OPEN.len() + end + 3; + let close = rest[body_start..] + .find(CLOSE) + .ok_or_else(|| format!("`{what}` has no {CLOSE}"))?; + out += &rest[..body_start]; + out += "\n"; + out += &generate(&what, l, all)?; + out += CLOSE; + rest = &rest[body_start + close + CLOSE.len()..]; + used.push(what); + } + out += rest; + Ok((out, used)) +} + +#[test] +fn the_manual_is_what_the_code_says() { + let all = schema::sections(); + let update = std::env::var_os(UPDATE).is_some(); + let mut errs = Vec::new(); + for (file, l) in [ + ("docs/config.md", Lang::En), + ("docs/config.zh-CN.md", Lang::Zh), + ] { + let path = workspace().join(file); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{file}: {e}")); + let (next, used) = match regenerate(&text, l, &all) { + Ok(x) => x, + Err(e) => { + errs.push(format!("{file}: {e}")); + continue; + } + }; + // 每一节都得在手册里出现,而且只出现一次 + for s in &all { + let n = used + .iter() + .filter(|u| **u == format!("table {}", s.path)) + .count(); + if n != 1 { + errs.push(format!( + "{file}: `table {}` appears {n} times; it has to appear once", + s.path + )); + } + } + for kind in schema::RULE_LISTS { + if !used.iter().any(|u| *u == format!("rules {kind}")) { + errs.push(format!("{file}: `rules {kind}` is missing")); + } + } + if next != text { + if update { + std::fs::write(&path, next).unwrap(); + } else { + errs.push(format!( + "{file} is out of date with the code. Regenerate it:\n \ + {UPDATE}=1 cargo test -p tw-config --test manual" + )); + } + } + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} + +/// 手册里的 YAML 示例读得进来。**只查写法,不查引用** —— 示例里的 +/// `pricing: relay-discount` 指向的价目表写在另一段示例里。 +/// +/// 从第一列开始写的示例才是整份配置的一段;缩进开头的是某一项的片段, +/// 带 `…` 的是占位,都跳过。还没进代码的那几节同样跳过,[`Ty::Pending`] 管它们。 +#[test] +fn the_examples_in_the_manual_parse() { + let all = schema::sections(); + let pending: Vec<&str> = all + .iter() + .filter(|s| matches!(s.ty, Ty::Pending { .. })) + .map(|s| s.path.rsplit('.').next().unwrap_or(s.path)) + .collect(); + let mut errs = Vec::new(); + for file in [ + "docs/config.md", + "docs/config.zh-CN.md", + "docs/server.md", + "docs/server.zh-CN.md", + ] { + let text = std::fs::read_to_string(workspace().join(file)).unwrap(); + let mut rest = text.as_str(); + while let Some(i) = rest.find("```yaml\n") { + let body = &rest[i + 8..]; + let end = body.find("```").expect("an unclosed code block"); + let block = &body[..end]; + rest = &body[end + 3..]; + let skip = block.starts_with(' ') + || block.contains('…') + || pending.iter().any(|p| block.contains(&format!("{p}:"))); + if skip { + continue; + } + if let Err(e) = + serde_yaml_ng::from_str::(&format!("version: 1\n{block}")) + { + errs.push(format!("{file}: an example does not parse: {e}\n{block}")); + } + } + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs new file mode 100644 index 00000000..b4ad9565 --- /dev/null +++ b/crates/tw-config/tests/manual/schema.rs @@ -0,0 +1,1431 @@ +//! config.yaml 的每一节、每一个字段,手册里那张表就是照这里渲染的。 +//! +//! **改了 tw-config(或者它引用的 tw-engine、tw-pricing)的配置类型,就改这里**: +//! 加一行、删一行、改默认值。`manual.rs` 会拿这份声明和代码逐项对,对不上时 +//! 说清楚是哪个字段、该怎么改。改完用 `UPDATE_CONFIG_DOCS=1` 重新生成手册。 +//! +//! 说明写给手写配置文件的人:这个字段管什么、不写是什么意思、写错了会怎样。 +//! 两种语言各写一遍,**不是互译的字面对照**,各自按各自的习惯说。 + +use super::{Def, Kind, Lang, Row, Section, T2, Ty}; +use tw_config::proxy::ProxyAuth; +use tw_config::*; +use tw_engine::rule::When; +use tw_engine::{Group, GroupType, RouteSet, Rule, SetAction}; +use tw_pricing::{PerMillion, PricingConfig, SheetDef}; + +const fn t(en: &'static str, zh: &'static str) -> T2 { + T2 { en, zh } +} + +const fn row(name: &'static str, kind: Kind, def: Def, doc: T2) -> Row { + Row { + name, + kind, + def, + doc, + } +} + +// 枚举的取值问 serde 要。函数指针要一个具体的函数,所以一个类型一个 +fn protocols() -> Vec<&'static str> { + super::fields::() +} +fn billings() -> Vec<&'static str> { + super::fields::() +} +fn proxy_kinds() -> Vec<&'static str> { + super::fields::() +} +fn on_proxy_fail() -> Vec<&'static str> { + super::fields::() +} +fn probe_actions() -> Vec<&'static str> { + super::fields::() +} +fn modes() -> Vec<&'static str> { + super::fields::() +} +fn tool_actions() -> Vec<&'static str> { + super::fields::() +} +fn content_actions() -> Vec<&'static str> { + super::fields::() +} +fn content_matches() -> Vec<&'static str> { + super::fields::() +} +fn group_types() -> Vec<&'static str> { + super::fields::() +} + +const RULE_ID: T2 = t("built-in rule id", "内置规则 id"); +const MODE_DOC: T2 = t( + "`off` does nothing; `observe` detects and records only, and changes nothing; `enforce` \ + detects and acts.", + "`off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。", +); +const ENABLE_DOC: T2 = t( + "Built-in rules to switch on that are off out of the box, by id.", + "打开出厂时关着的内置规则,按 id。", +); +const DISABLE_DOC: T2 = t( + "Built-in rules to switch off, by id.", + "关掉内置规则,按 id。", +); +const RULE_NAME: T2 = t( + "Name shown in logs and in the app; it identifies the rule and has to be unique within this \ + guard.", + "日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。", +); +const RULE_DISABLED: T2 = t( + "Switches the rule off and keeps it in the file.", + "停用这条规则,规则本身留在文件里。", +); + +/// 手册里要有的内置规则清单,``。 +pub const RULE_LISTS: &[&str] = &["redact", "inspect_tools", "hidden_text", "content"]; + +pub fn sections() -> Vec
{ + vec![ + // ── 顶层 ────────────────────────────────────────────── + Section { + path: "config", + ty: checked!(Config, "version: 1"), + rows: vec![ + row( + "version", + Kind::Int, + Def::Required, + t( + "Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood.", + "文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。", + ), + ), + row( + "listen", + Kind::Obj("listen"), + Def::Section, + t( + "Where the gateway and the control channel listen.", + "网关和控制通道在哪里监听。", + ), + ), + row( + "clients", + Kind::Objs("clients[]"), + Def::Is("[]"), + t( + "Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`.", + "网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。", + ), + ), + row( + "providers", + Kind::Objs("providers[]"), + Def::Is("[]"), + t( + "Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured.", + "上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。", + ), + ), + row( + "proxies", + Kind::Objs("proxies[]"), + Def::Is("[]"), + t( + "Outbound proxies, declared once and referred to by name from `providers[].proxy`.", + "出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。", + ), + ), + row( + "pricing", + Kind::Obj("pricing"), + Def::Section, + t( + "Refreshing the default price table, and price sheets of your own.", + "默认价目表是否定期刷新,以及自定义价目表。", + ), + ), + row( + "client_probes", + Kind::Obj("client_probes"), + Def::Section, + t( + "What happens to the helper requests clients send on their own (health checks, warm-ups, titles).", + "客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。", + ), + ), + row( + "security", + Kind::Obj("security"), + Def::Section, + t( + "The five guards. All of them start in `observe` or `off`, so out of the box nothing is changed or blocked.", + "五项防护。出厂时都处在 `observe` 或 `off`,不改变、不拦截任何请求。", + ), + ), + row( + "retention", + Kind::Obj("retention"), + Def::Section, + t("How long request logs are kept.", "请求日志保留多久。"), + ), + row( + "groups", + Kind::Objs("groups[]"), + Def::Is("[]"), + t( + "Strategy groups: several upstreams behind one name, with a way to pick among them.", + "策略组:多个上游合用一个名字,并规定如何在其中选择。", + ), + ), + row( + "routes", + Kind::Objs("routes[]"), + Def::Is("[]"), + t( + "Routes. Without any, requests fail over across all upstreams in the order they are declared.", + "路由。一条都不写时,请求按上游的声明顺序故障转移。", + ), + ), + row( + "default_route", + Kind::Str, + Def::Unset, + t( + "The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none.", + "未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。", + ), + ), + row( + "default_key", + Kind::Str, + Def::Unset, + t( + "The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled.", + "没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。", + ), + ), + ], + }, + // ── listen ──────────────────────────────────────────── + Section { + path: "listen", + ty: checked!(Listen, "{}"), + rows: vec![row( + "gateway", + Kind::Obj("listen.gateway"), + Def::Section, + t( + "The AI gateway: the address clients send requests to.", + "AI 网关,即客户端发送请求的地址。", + ), + )], + }, + Section { + path: "listen.gateway", + ty: checked!(GatewayListen, "{}"), + rows: vec![ + row( + "bind", + Kind::Bind, + Def::Is("loopback"), + t( + "`loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up at start and follows address changes; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1.", + "`loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。", + ), + ), + row( + "port", + Kind::Int, + Def::Is("8788"), + t("TCP port of the gateway.", "网关的 TCP 端口。"), + ), + row( + "allow_from", + Kind::Strs, + Def::Is("[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]"), + t( + "Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out.", + "本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。", + ), + ), + ], + }, + Section { + path: "listen.control", + ty: Ty::Pending { + probe: "version: 1\nlisten:\n control: {}\n", + }, + rows: vec![ + row( + "key", + Kind::Str, + Def::Said(t("generated", "自动生成")), + t( + "The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`.", + "控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。", + ), + ), + row( + "remote", + Kind::Obj("listen.control.remote"), + Def::Section, + t( + "A network port for the desktop app on another machine. Additional to the local channel, never instead of it.", + "供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。", + ), + ), + ], + }, + Section { + path: "listen.control.remote", + ty: Ty::Pending { + probe: "version: 1\nlisten:\n control:\n remote: {}\n", + }, + rows: vec![ + row( + "enabled", + Kind::Bool, + Def::Is("false"), + t( + "Listen on the remote port. Unset or `false`: no network port is opened for control.", + "是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。", + ), + ), + row( + "bind", + Kind::Bind, + Def::Is("all"), + t( + "Interface to listen on, written as for `listen.gateway.bind`.", + "监听哪张网卡,写法同 `listen.gateway.bind`。", + ), + ), + row( + "port", + Kind::Int, + Def::Said(t("generated", "自动生成")), + t( + "TCP port. There is no fixed default: a random free port is written when the section is generated, like the key.", + "TCP 端口。没有固定默认值:和密钥一样,生成这一节时写入一个随机端口。", + ), + ), + row( + "allow_from", + Kind::Strs, + Def::Is("[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]"), + t( + "Sources that may connect, as for `listen.gateway.allow_from`. A connection from anywhere else is closed before the handshake, without a byte in reply.", + "允许连接的来源,写法同 `listen.gateway.allow_from`。其他来源的连接在握手之前关闭,不回任何字节。", + ), + ), + ], + }, + // ── clients ─────────────────────────────────────────── + Section { + path: "clients[]", + ty: checked!(Client, "{name: a, key: k}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the key; unique. Routing rules match it with `when.client`.", + "密钥的名字,不能重复。路由规则用 `when.client` 匹配它。", + ), + ), + row( + "key", + Kind::Str, + Def::Required, + t( + "The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique.", + "客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。", + ), + ), + row( + "max_concurrent", + Kind::Int, + Def::Unset, + t( + "Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused.", + "用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。", + ), + ), + row( + "allow", + Kind::Strs, + Def::Unset, + t( + "Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all.", + "这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。", + ), + ), + row( + "route", + Kind::Str, + Def::Unset, + t( + "Name of the route requests with this key take. Unset: `default_route`.", + "这把密钥的请求走哪条路由。不写:`default_route`。", + ), + ), + row( + "client", + Kind::Str, + Def::Unset, + t( + "The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one.", + "这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。", + ), + ), + row( + "disabled", + Kind::Bool, + Def::Is("false"), + t( + "Refuse every request made with this key, and keep the key.", + "拒绝使用这把密钥的所有请求,密钥本身保留。", + ), + ), + ], + }, + // ── providers ───────────────────────────────────────── + Section { + path: "providers[]", + ty: checked!(Provider, "{name: p, base_url: 'https://api.example.com'}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved.", + "上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。", + ), + ), + row( + "base_url", + Kind::Str, + Def::Required, + t( + "Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`).", + "接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。", + ), + ), + row( + "key", + Kind::Secret, + Def::Unset, + t( + "API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth`.", + "API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth` 同时写。", + ), + ), + row( + "headers", + Kind::Headers, + Def::Is("{}"), + t( + "Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set.", + "额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。", + ), + ), + row( + "oauth", + Kind::Obj("providers[].oauth"), + Def::Unset, + t( + "OAuth credential: an access token obtained from a refresh token. Instead of `key`.", + "OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。", + ), + ), + row( + "protocol", + Kind::Enum(protocols), + Def::Unset, + t( + "API format of the upstream. Unset: recognized from `base_url` for the official endpoints, otherwise treated as `anthropic`.", + "上游的接口格式。不写:官方地址按 `base_url` 识别,其余按 `anthropic` 处理。", + ), + ), + row( + "proxy", + Kind::Str, + Def::Is("direct"), + t( + "`direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`.", + "`direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。", + ), + ), + row( + "on_proxy_fail", + Kind::Enum(on_proxy_fail), + Def::Is("fail"), + t( + "When the proxy cannot be reached: `fail` the request, or go `direct`.", + "代理不可用时:请求失败(`fail`),或改为直连(`direct`)。", + ), + ), + row( + "models", + Kind::Strs, + Def::Is("[]"), + t( + "Models to assume when the upstream does not answer `/v1/models`.", + "上游不支持 `/v1/models` 时,按这份清单认定它提供的模型。", + ), + ), + row( + "models_only", + Kind::Strs, + Def::Unset, + t( + "Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`.", + "只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。", + ), + ), + row( + "billing", + Kind::Enum(billings), + Def::Is("per-token"), + t( + "`per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0.", + "`per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。", + ), + ), + row( + "pricing", + Kind::Str, + Def::Unset, + t( + "Name of a price sheet under `pricing.sheets`. Unset: the default price table.", + "`pricing.sheets` 中某张价目表的名字。不写:默认价目表。", + ), + ), + row( + "disabled", + Kind::Bool, + Def::Is("false"), + t( + "Take the upstream out of routing and out of the model list, and keep its configuration.", + "不参与路由,模型也不出现在模型列表里;配置原样保留。", + ), + ), + ], + }, + Section { + path: "providers[].oauth", + ty: checked!( + OAuth, + "{refresh: r, endpoint: 'https://auth.example.com/token'}" + ), + rows: vec![ + row( + "access", + Kind::Str, + Def::Unset, + t( + "Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use.", + "当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。", + ), + ), + row( + "expires_at", + Kind::Str, + Def::Unset, + t( + "When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401.", + "`access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。", + ), + ), + row( + "refresh", + Kind::Str, + Def::Required, + t( + "Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file.", + "Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。", + ), + ), + row( + "endpoint", + Kind::Str, + Def::Required, + t("Token endpoint URL.", "token 端点的地址。"), + ), + row( + "client_id", + Kind::Str, + Def::Unset, + t( + "OAuth client id, if the endpoint wants one.", + "OAuth 客户端 ID,端点需要时填写。", + ), + ), + row( + "client_secret", + Kind::Str, + Def::Unset, + t( + "OAuth client secret, if the endpoint wants one.", + "OAuth 客户端密钥,端点需要时填写。", + ), + ), + row( + "refresh_before", + Kind::Duration, + Def::Unset, + t( + "How long before expiry to refresh. Unset or unreadable: `5m`.", + "提前多久刷新。不写或写法无法识别:`5m`。", + ), + ), + ], + }, + // ── proxies ─────────────────────────────────────────── + Section { + path: "proxies[]", + ty: checked!(Proxy, "{name: p, addr: '127.0.0.1:7890'}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name used in `providers[].proxy`. `direct` and `system` are built in.", + "`providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。", + ), + ), + row( + "type", + Kind::Enum(proxy_kinds), + Def::Is("socks5h"), + t( + "`socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies.", + "`socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。", + ), + ), + row( + "addr", + Kind::Str, + Def::Required, + t("`host:port` of the proxy.", "代理的 `host:port`。"), + ), + row( + "auth", + Kind::Obj("proxies[].auth"), + Def::Unset, + t( + "User name and password, if the proxy wants them.", + "代理需要时填写用户名和密码。", + ), + ), + ], + }, + Section { + path: "proxies[].auth", + ty: checked!(ProxyAuth, "{user: u, pass: p}"), + rows: vec![ + row( + "user", + Kind::Str, + Def::Required, + t("User name.", "用户名。"), + ), + row( + "pass", + Kind::Secret, + Def::Required, + t("Password.", "密码。"), + ), + ], + }, + // ── pricing ─────────────────────────────────────────── + Section { + path: "pricing", + ty: checked!(PricingConfig, "{}"), + rows: vec![ + row( + "auto_update", + Kind::Bool, + Def::Is("true"), + t( + "Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline.", + "每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。", + ), + ), + row( + "sheets", + Kind::Objs("pricing.sheets[]"), + Def::Is("[]"), + t( + "Price sheets of your own. An upstream uses one with `providers[].pricing`.", + "自定义价目表。上游用 `providers[].pricing` 选用。", + ), + ), + ], + }, + Section { + path: "pricing.sheets[]", + ty: checked!(SheetDef, "{name: s}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t("Name of the sheet; unique.", "价目表的名字,不能重复。"), + ), + row( + "multiplier", + Kind::Num, + Def::Is("1"), + t( + "Applied to every price of the default table, cache and long-context prices included.", + "作用于默认价目表的全部单价,包括缓存和长上下文单价。", + ), + ), + row( + "models", + Kind::ObjMap(t("model id", "模型 ID"), "pricing.sheets[].models.*"), + Def::Is("{}"), + t( + "Prices for single models. They replace the default table's price for that model and are not multiplied.", + "单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。", + ), + ), + ], + }, + Section { + path: "pricing.sheets[].models.*", + ty: checked!( + PerMillion, + "{input: 3, output: 15, cache_read: 0.3, cache_write_5m: 3.75, cache_write_1h: 6}" + ), + rows: vec![ + row( + "input", + Kind::Num, + Def::Required, + t( + "US dollars per million input tokens.", + "每百万输入 token 的美元价格。", + ), + ), + row( + "output", + Kind::Num, + Def::Required, + t( + "US dollars per million output tokens.", + "每百万输出 token 的美元价格。", + ), + ), + row( + "cache_read", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens read from the prompt cache.", + "每百万缓存读取 token 的美元价格。", + ), + ), + row( + "cache_write_5m", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens written to a 5-minute cache.", + "每百万写入 5 分钟缓存 token 的美元价格。", + ), + ), + row( + "cache_write_1h", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens written to a 1-hour cache.", + "每百万写入 1 小时缓存 token 的美元价格。", + ), + ), + row( + "input_above_200k", + Kind::Num, + Def::Unset, + t( + "Input price once a request's input exceeds 200K tokens. Written together with `output_above_200k`, or neither.", + "单次请求输入超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写。", + ), + ), + row( + "output_above_200k", + Kind::Num, + Def::Unset, + t( + "Output price once a request's input exceeds 200K tokens.", + "单次请求输入超过 200K token 后的输出单价。", + ), + ), + ], + }, + // ── client_probes ───────────────────────────────────── + Section { + path: "client_probes", + ty: checked!(ClientProbes, "{}"), + rows: vec![ + row( + "health_check", + Kind::Enum(probe_actions), + Def::Is("intercept"), + t( + "Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost.", + "连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。", + ), + ), + row( + "warmup", + Kind::Enum(probe_actions), + Def::Is("intercept"), + t( + "Warm-up requests. Answered locally by default.", + "预热请求。默认在本地应答。", + ), + ), + row( + "titling", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Requests that name a session. Passed through by default: intercepting them gives every session the same title.", + "为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。", + ), + ), + row( + "topic_detect", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Topic detection. Passed through by default.", + "话题检测。默认放行。", + ), + ), + row( + "suggestion", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Suggestions. Passed through by default.", + "建议。默认放行。", + ), + ), + ], + }, + // ── security ────────────────────────────────────────── + Section { + path: "security", + ty: checked!(Security, "{}"), + rows: vec![ + row( + "redact", + Kind::Obj("security.redact"), + Def::Section, + t( + "Outbound redaction: credentials found in a request are replaced before it leaves.", + "出站脱敏:请求发出前,把其中的凭据替换掉。", + ), + ), + row( + "inspect_tools", + Kind::Obj("security.inspect_tools"), + Def::Section, + t( + "Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off.", + "工具调用审查:模型返回的工具调用中出现危险命令时切断响应。", + ), + ), + row( + "hidden_text", + Kind::Obj("security.hidden_text"), + Def::Section, + t( + "Hidden characters that people cannot see and models can read refuse the request.", + "人看不见、模型读得到的隐藏字符,出现时拒绝请求。", + ), + ), + row( + "content", + Kind::Obj("security.content"), + Def::Section, + t( + "Content filter: words or patterns in what the caller sends refuse the request.", + "内容过滤:调用方发送的内容中出现指定的词或写法时拒绝请求。", + ), + ), + row( + "output_limit", + Kind::Obj("security.output_limit"), + Def::Section, + t( + "Output length: a response longer than the limit is cut off.", + "输出长度:回答超过上限时切断。", + ), + ), + ], + }, + Section { + path: "security.redact", + ty: checked!(RedactPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "custom", + Kind::Objs("security.redact.custom[]"), + Def::Is("[]"), + t( + "Rules of your own: whatever a pattern matches is treated as a credential.", + "自定义规则:正则匹配到的内容按凭据处理。", + ), + ), + ], + }, + Section { + path: "security.redact.custom[]", + ty: checked!(CustomRedactRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t("Regular expression.", "正则表达式。"), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.inspect_tools", + ty: checked!(ToolPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "actions", + Kind::EnumMap(RULE_ID, tool_actions), + Def::Is("{}"), + t( + "What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`).", + "内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。", + ), + ), + row( + "custom", + Kind::Objs("security.inspect_tools.custom[]"), + Def::Is("[]"), + t( + "Rules of your own, matched against the arguments of a tool call.", + "自定义规则,按工具调用的参数匹配。", + ), + ), + ], + }, + Section { + path: "security.inspect_tools.custom[]", + ty: checked!(CustomToolRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t("Regular expression.", "正则表达式。"), + ), + row( + "action", + Kind::Enum(tool_actions), + Def::Is("record"), + t( + "Under `enforce`: `cut` the response off, or only `record` the match.", + "`enforce` 下切断响应(`cut`),或只记录(`record`)。", + ), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.hidden_text", + ty: checked!(HiddenPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row( + "disable", + Kind::Strs, + Def::Is("[]"), + t( + "Kinds not to look for: `tag`, `bidi`.", + "不检查的种类:`tag`、`bidi`。", + ), + ), + ], + }, + Section { + path: "security.content", + ty: checked!(ContentPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "actions", + Kind::EnumMap(RULE_ID, content_actions), + Def::Is("{}"), + t( + "What a built-in rule does under `enforce`, written only where it differs from the factory setting.", + "内置规则在 `enforce` 下的处置,只写与出厂不同的。", + ), + ), + row( + "custom", + Kind::Objs("security.content.custom[]"), + Def::Is("[]"), + t("Rules of your own.", "自定义规则。"), + ), + ], + }, + Section { + path: "security.content.custom[]", + ty: checked!(CustomContentRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t( + "A keyword, or a regular expression with `match: regex`. Case-insensitive either way.", + "关键词;`match: regex` 时为正则表达式。均不区分大小写。", + ), + ), + row( + "match", + Kind::Enum(content_matches), + Def::Is("contains"), + t( + "`contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression.", + "`contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。", + ), + ), + row( + "action", + Kind::Enum(content_actions), + Def::Is("record"), + t( + "Under `enforce`: `block` the request, or only `record` the match.", + "`enforce` 下拒绝请求(`block`),或只记录(`record`)。", + ), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.output_limit", + ty: checked!(OutputLimitPolicy, "{}"), + rows: vec![ + row( + "mode", + Kind::Enum(modes), + Def::Is("off"), + t( + "Off out of the box: no single limit suits every use. `observe` records long responses; `enforce` stops the stream at the limit.", + "出厂关闭:没有一个上限适合所有用途。`observe` 记录超长的回答;`enforce` 在超过上限处停止输出。", + ), + ), + row( + "max_chars", + Kind::Int, + Def::Is("100000"), + t( + "Limit in characters (Unicode scalar values), from 1 to 1000000.", + "上限,按字符(Unicode 标量)计,取值 1 到 1000000。", + ), + ), + ], + }, + // ── retention ───────────────────────────────────────── + Section { + path: "retention", + ty: checked!(Retention, "{}"), + rows: vec![ + row( + "body_days", + Kind::Int, + Def::Is("7"), + t( + "Days to keep request and response bodies.", + "请求和响应正文保留的天数。", + ), + ), + row( + "row_days", + Kind::Int, + Def::Is("90"), + t( + "Days to keep the record of each request (time, model, usage, cost).", + "每条请求记录(时间、模型、用量、费用)保留的天数。", + ), + ), + row( + "body_max_bytes", + Kind::Int, + Def::Is("2147483648"), + t( + "Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 2 GiB.", + "正文最多占用的字节数,超出时从最早的日期开始删除。默认 2 GiB。", + ), + ), + ], + }, + // ── groups / routes ─────────────────────────────────── + Section { + path: "groups[]", + ty: checked!(Group, "{name: g, providers: [a]}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the group; unique, and not the name of an upstream.", + "策略组的名字,不能重复,也不能和上游同名。", + ), + ), + row( + "type", + Kind::Enum(group_types), + Def::Is("fallback"), + t( + "`fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price.", + "`fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。", + ), + ), + row( + "providers", + Kind::Strs, + Def::Required, + t("Member upstreams, by name.", "成员上游的名字。"), + ), + row( + "session_affinity", + Kind::Bool, + Def::Is("true"), + t( + "Keep a session on the same upstream so its prompt cache keeps hitting. Turning it off under `load-balance` spreads every turn and loses the cache.", + "同一会话固定走同一家,使 prompt cache 持续命中。在 `load-balance` 下关闭会让每一轮都换一家,缓存随之失效。", + ), + ), + row( + "selected", + Kind::Str, + Def::Unset, + t( + "For `select`: the chosen member.", + "`select` 类型选中的成员。", + ), + ), + ], + }, + Section { + path: "routes[]", + ty: checked!(RouteSet, "{name: r}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the route; unique. `default` is the one keys use unless told otherwise.", + "路由的名字,不能重复。`default` 是密钥默认使用的那条。", + ), + ), + row( + "rules", + Kind::Objs("routes[].rules[]"), + Def::Is("[]"), + t( + "Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes.", + "自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。", + ), + ), + ], + }, + Section { + path: "routes[].rules[]", + ty: checked!(Rule, "{name: r}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name shown in logs and in the traffic view.", + "日志和流量详情中显示的名字。", + ), + ), + row( + "when", + Kind::Obj("routes[].rules[].when"), + Def::Unset, + t( + "Conditions, all of which have to hold. Unset: matches every request.", + "条件,须全部满足。不写:匹配所有请求。", + ), + ), + row( + "to", + Kind::Str, + Def::Unset, + t( + "An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`.", + "上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。", + ), + ), + row( + "set", + Kind::Obj("routes[].rules[].set"), + Def::Unset, + t( + "Parameters to rewrite. Collected from every matching rule, not only the first.", + "改写请求参数。从所有匹配的规则累积,不只第一条。", + ), + ), + row( + "deny", + Kind::Str, + Def::Unset, + t( + "Refuse the request with this reason.", + "以这句原因拒绝请求。", + ), + ), + ], + }, + Section { + path: "routes[].rules[].when", + ty: checked!(When, "{}"), + rows: vec![ + row( + "model", + Kind::Str, + Def::Unset, + t( + "Requested model, glob (`claude-opus-*`).", + "请求的模型,可用通配(`claude-opus-*`)。", + ), + ), + row( + "client", + Kind::Str, + Def::Unset, + t( + "Name of the gateway key the request used, exactly.", + "请求所用网关密钥的名字,精确匹配。", + ), + ), + row( + "dialect", + Kind::Str, + Def::Unset, + t( + "API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`.", + "客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。", + ), + ), + row( + "input_tokens", + Kind::Compare, + Def::Unset, + t("Estimated input tokens.", "估算的输入 token 数。"), + ), + row( + "max_tokens", + Kind::Compare, + Def::Unset, + t( + "The request's `max_tokens`. A request without one never matches.", + "请求中的 `max_tokens`。未写该参数的请求不匹配。", + ), + ), + row( + "tool_count", + Kind::Compare, + Def::Unset, + t("Number of tools offered.", "请求中提供的工具数量。"), + ), + row( + "cache", + Kind::Bool, + Def::Unset, + t( + "Whether the request uses the prompt cache.", + "请求是否使用 prompt cache。", + ), + ), + row( + "tools", + Kind::Bool, + Def::Unset, + t("Whether the request offers tools.", "请求是否带工具。"), + ), + row( + "image", + Kind::Bool, + Def::Unset, + t( + "Whether the request contains an image.", + "请求是否包含图片。", + ), + ), + row( + "thinking", + Kind::Bool, + Def::Unset, + t("Whether extended thinking is on.", "是否开启扩展思考。"), + ), + row( + "stream", + Kind::Bool, + Def::Unset, + t("Whether the response is streamed.", "是否流式返回。"), + ), + row( + "intent", + Kind::OneOrMany, + Def::Unset, + t( + "A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing.", + "客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。", + ), + ), + row( + "provider_would_be", + Kind::OneOrMany, + Def::Unset, + t( + "The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`.", + "路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。", + ), + ), + ], + }, + Section { + path: "routes[].rules[].set", + ty: checked!(SetAction, "{}"), + rows: vec![ + row( + "model", + Kind::Str, + Def::Unset, + t( + "Send a different model. The prompt cache of the session is lost.", + "换成另一个模型发送。该会话的 prompt cache 随之失效。", + ), + ), + row( + "max_tokens", + Kind::Int, + Def::Unset, + t("Replace `max_tokens`.", "替换 `max_tokens`。"), + ), + row( + "thinking", + Kind::Bool, + Def::Unset, + t("Turn extended thinking on or off.", "开启或关闭扩展思考。"), + ), + row( + "only_at_session_start", + Kind::Bool, + Def::Is("false"), + t( + "Apply only when a session starts. Recorded and shown; not in effect yet.", + "只在会话开始时应用。目前只记录和显示,尚未生效。", + ), + ), + ], + }, + ] +} + +/// 一份内置规则清单,渲染成表。 +pub fn rules(kind: &str, l: Lang) -> Option { + let zh = l == Lang::Zh; + let yes = if zh { "开" } else { "on" }; + let no = if zh { "关" } else { "off" }; + let mut out = String::new(); + match kind { + "redact" => { + out += if zh { + "| id | 名称 | 出厂 |\n|---|---|---|\n" + } else { + "| id | Name | Out of the box |\n|---|---|---|\n" + }; + for b in tw_guard::redact::rules::BUILTINS { + let on = if b.on_by_default { yes } else { no }; + out += &format!("| `{}` | {} | {on} |\n", b.id, b.name); + } + } + "inspect_tools" => { + out += if zh { + "| id | 名称 | `enforce` 下出厂处置 |\n|---|---|---|\n" + } else { + "| id | Name | Under `enforce`, out of the box |\n|---|---|---|\n" + }; + for s in &tw_guard::tools::rules::builtin().dangerous { + let a = ToolAction::factory(s).slug(); + out += &format!("| `{}` | {} | `{a}` |\n", s.id, s.name); + } + } + "content" => { + out += if zh { + "| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 |\n|---|---|---|---|---|\n" + } else { + "| id | Name | Group | Out of the box | Under `enforce`, out of the box |\n|---|---|---|---|---|\n" + }; + for b in tw_guard::content::builtins() { + let on = if b.on_by_default { yes } else { no }; + let a = ContentAction::factory(b).slug(); + out += &format!("| `{}` | {} | {} | {on} | `{a}` |\n", b.id, b.name, b.group); + } + } + "hidden_text" => { + out += if zh { + "| 种类 | 说明 |\n|---|---|\n" + } else { + "| Kind | What it is |\n|---|---|\n" + }; + for k in tw_guard::hidden::SMUGGLING { + let what = match (k.slug(), zh) { + ("tag", false) => { + "Unicode tag characters (U+E0000 to U+E007F): invisible everywhere, read by the model, able to carry a whole instruction." + } + ("tag", true) => { + "Unicode 标签字符(U+E0000 至 U+E007F):在任何地方都不可见,模型却能读到,足以藏下一整段指令。" + } + ("bidi", false) => { + "Bidirectional control characters: make the order shown differ from the order the model reads." + } + ("bidi", true) => "双向控制符:使显示顺序与模型读到的顺序不一致。", + (other, _) => panic!("hidden kind `{other}` has no description in the manual"), + }; + out += &format!("| `{}` | {what} |\n", k.slug()); + } + } + _ => return None, + } + Some(out) +} diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 00000000..f87757e3 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,867 @@ +# Configuration reference + +[中文](config.zh-CN.md) + +ThinkWatch Core reads one file, `config.yaml`. This page describes every +field in it: what it does, its default, the values it takes, and how a +change reaches the running process. To run core on a server and control it +from the desktop app, see [Running core on a server](server.md). + +The field tables on this page are generated from the code, and a test fails +when they disagree, so a field listed here is a field the binary reads. + +## Where the file is + +| Platform | Default location | +|---|---| +| macOS, Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` replaces the directory, and `--config ` names the +file for a single command. The directory holds everything else core keeps +as well: the request database (`data.db`), the configuration history +(`history/`), the downloaded price table (`model_prices.json`) and the local +control socket (`twcore.sock`; on Windows a loopback port recorded in +`control.port`). The directory is private to its owner (`0700`), the file is +`0600`: it holds keys in plain text. + +`twcore serve` writes a starting configuration when there is none, and +`twcore init` writes one on request. Both produce this: + +```yaml +version: 1 +clients: + - name: default + key: tw-… # generated +``` + +That is a complete, valid configuration. It has no upstream yet, so the +control plane runs and requests are answered with an error saying so. +Adding one upstream makes it forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## How the file is read + +- **A field name that is not in this reference is an error.** A misspelled + `prot:` is not ignored while the gateway quietly starts on the default + port; the whole file is refused and the message names the field. +- **Defaults are not written into the file.** Anything left out has the + default in the tables below. The app and the command line add a field + only when its value differs from the default, so what is in the file is + what someone chose. +- **`${VAR}` reads an environment variable** in fields marked "`${VAR}` + allowed": upstream keys, header values, proxy passwords. It is read from + the environment of the core process when a request is sent; an unset + variable fails that upstream's requests and names the variable. Under + systemd, that environment is the unit's `EnvironmentFile`. +- **Names are references.** Rules, groups and keys refer to upstreams, + groups, routes and price sheets by name. A name that points nowhere is an + error at load time rather than a rule that never matches. Renaming in the + app changes every reference in the same write. Names starting with `__` + are reserved for built-ins. +- **`version`** is the format version, `1`. A file with a higher version was + written by a newer twcore and is refused. + +## How a change takes effect + +There are three ways to change the configuration, and they all go through +the same path: the desktop app, `twcore config …`, and editing the file +in an editor. Core watches the file and reloads it within a second of a +save; nothing needs to be restarted. + +A new version is applied only if it passes every stage: + +1. It parses as YAML. +2. It matches the schema: known fields, the right types. +3. It is consistent: names are unique, references resolve, patterns + compile, CIDR ranges are well formed. +4. The runtime objects can be built from it. + +If any stage fails, **the previous configuration stays in service**, and the +error says which stage failed and where. A typo never takes the gateway +down. The desktop app shows the rejection until a valid version is saved. + +Changes to `listen.gateway` apply live as well: core opens the new +listener, and if it cannot (the port is taken) it keeps the old one and +reports why. Retention changes are applied on the next hourly clean-up. + +When two writers edit at once (the app and a hand edit), the second write +is refused with a version mismatch instead of overwriting the first. + +### History and rollback + +Every version that was in effect is kept in `history/` beside the file, +with where it came from (the app, the command line, an outside edit, a +rollback, a credential rotation). The last 50 are kept. + +```sh +twcore check # validate the file without starting anything +twcore config show # print it, with its version +twcore config history # list the versions, newest first +twcore config rollback 3f9a2c # go back to a version (a prefix is enough) +twcore config set /listen/gateway/port 8790 --int +``` + +These commands work on the file directly, so they work when core is not +running, which is when a rollback is most needed. A running core picks +their changes up like any other save. + +`twcore config set ` changes one value that is already written +in the file. The path names list items by their `name` +(`/providers/anthropic/base_url`); a number is an index (`/routes/0/rules/1/to`). +The value is a string unless `--int`, `--bool` or `--null` says otherwise. +The result is validated before it is written. To add a field that is not in +the file yet, edit the file. + +### Credentials the gateway writes back + +One write is not made by a person. When an upstream's OAuth token endpoint +issues a new refresh token, the old one stops working, so the gateway +writes the new token (and the access token with its expiry) back into +`providers[].oauth`. Only those values change; comments and layout are left +as they are. + +## Reference + +Each table lists every field of one section. "—" in the Default column means +the field is simply absent unless written; the description says what that +means. + +### Top level + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `version` | integer | **required** | Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood. | +| `listen` | object, [`listen`](#cfg-listen) | — | Where the gateway and the control channel listen. | +| `clients` | list of [`clients[]`](#cfg-clients) | `[]` | Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`. | +| `providers` | list of [`providers[]`](#cfg-providers) | `[]` | Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured. | +| `proxies` | list of [`proxies[]`](#cfg-proxies) | `[]` | Outbound proxies, declared once and referred to by name from `providers[].proxy`. | +| `pricing` | object, [`pricing`](#cfg-pricing) | — | Refreshing the default price table, and price sheets of your own. | +| `client_probes` | object, [`client_probes`](#cfg-client_probes) | — | What happens to the helper requests clients send on their own (health checks, warm-ups, titles). | +| `security` | object, [`security`](#cfg-security) | — | The five guards. All of them start in `observe` or `off`, so out of the box nothing is changed or blocked. | +| `retention` | object, [`retention`](#cfg-retention) | — | How long request logs are kept. | +| `groups` | list of [`groups[]`](#cfg-groups) | `[]` | Strategy groups: several upstreams behind one name, with a way to pick among them. | +| `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | +| `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | +| `default_key` | string | — | The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled. | + + +### `listen` + +Where core accepts connections. There are two kinds: the AI gateway that +clients send requests to, and the control channel the desktop app and +`twcore` commands use. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `gateway` | object, [`listen.gateway`](#cfg-listen-gateway) | — | The AI gateway: the address clients send requests to. | + + +#### `listen.gateway` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| interface name \| IP address | `loopback` | `loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up at start and follows address changes; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1. | +| `port` | integer | `8788` | TCP port of the gateway. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out. | + + +When `bind` reaches beyond this machine, `allow_from` decides who gets in. +The gateway has no TLS: expose it on networks you trust, or put it behind a +tunnel or VPN. + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +The control channel is how the desktop app, and `twcore config` and +`twcore control-key` on the same machine, talk to core. Locally it is a +socket file in the data directory (a loopback port on Windows); no network +port is opened for it unless `remote` is enabled. + +Every control connection, local or remote, starts with a handshake that +proves both ends hold `key` +(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`: the key is the pre-shared key, +each connection negotiates fresh session keys, and the traffic is +encrypted). There are no certificates. A peer without the key cannot +complete the first message. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `key` | string | generated | The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`. | +| `remote` | object, [`listen.control.remote`](#cfg-listen-control-remote) | — | A network port for the desktop app on another machine. Additional to the local channel, never instead of it. | + + +`key` is written by `twcore serve` before the control channel starts +listening, if it is missing, and by `twcore init`. It is masked wherever the +configuration is shown or kept in the history; saving a masked value back +keeps the real one. A missing or malformed key (not 64 hexadecimal +characters) makes the whole file invalid, so it cannot be replaced by a +short, guessable one. + +```sh +twcore control-key # print the key, to paste into the desktop app +twcore control-key --rotate # replace it; connected apps have to reconnect +``` + +Both run on the machine where core runs. + +#### `listen.control.remote` + +A network port for a desktop app on another machine. It is opened in +addition to the local channel, so a mistake here (a port that is taken, an +`allow_from` that shuts you out) never locks out the machine itself: +`twcore config` and the local app keep working. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. | +| `bind` | `loopback` \| `all` \| interface name \| IP address | `all` | Interface to listen on, written as for `listen.gateway.bind`. | +| `port` | integer | generated | TCP port. There is no fixed default: a random free port is written when the section is generated, like the key. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources that may connect, as for `listen.gateway.allow_from`. A connection from anywhere else is closed before the handshake, without a byte in reply. | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 hexadecimal characters + remote: + enabled: true + bind: all + port: 41327 # random, written when the section is generated + allow_from: [192.168.1.0/24] +``` + +A connection over the remote port cannot do three things, whatever the app +asks: shut core down (it is managed by systemd), change `listen.control` +(the door it came in through), or produce a diagnostic bundle (it would be +written on the server). Handshakes time out after 5 seconds; five failed +handshakes from one source within a minute block that source for a minute. + +### `clients` + +Gateway keys: the keys clients such as Claude Code and Codex send to the +gateway. A key is an identity. Limits, model scope and route are per key. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the key; unique. Routing rules match it with `when.client`. | +| `key` | string | **required** | The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique. | +| `max_concurrent` | integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused. | +| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all. | +| `route` | string | — | Name of the route requests with this key take. Unset: `default_route`. | +| `client` | string | — | The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one. | +| `disabled` | bool | `false` | Refuse every request made with this key, and keep the key. | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +Upstreams: the APIs requests are forwarded to. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved. | +| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). | +| `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth`. | +| `headers` | map of header name → value | `{}` | Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set. | +| `oauth` | object, [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth credential: an access token obtained from a refresh token. Instead of `key`. | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | API format of the upstream. Unset: recognized from `base_url` for the official endpoints, otherwise treated as `anthropic`. | +| `proxy` | string | `direct` | `direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`. | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | When the proxy cannot be reached: `fail` the request, or go `direct`. | +| `models` | list of strings | `[]` | Models to assume when the upstream does not answer `/v1/models`. | +| `models_only` | list of strings | — | Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`. | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0. | +| `pricing` | string | — | Name of a price sheet under `pricing.sheets`. Unset: the default price table. | +| `disabled` | bool | `false` | Take the upstream out of routing and out of the model list, and keep its configuration. | + + +A credential is one of three things: `key`, which goes in the header the +protocol expects; `oauth`, a token obtained from a refresh token; or +`headers`, when the upstream wants something of its own. `headers` can be +combined with the other two, except for the header that already carries the +credential. + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +A ChatGPT account upstream (`protocol: chatgpt`) takes only the credential +the desktop app obtains by signing in; it cannot be written by hand. Claude +and Google subscription sign-ins are not supported; use an API key. + +#### `providers[].oauth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `access` | string | — | Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use. | +| `expires_at` | string | — | When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401. | +| `refresh` | string | **required** | Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file. | +| `endpoint` | string | **required** | Token endpoint URL. | +| `client_id` | string | — | OAuth client id, if the endpoint wants one. | +| `client_secret` | string | — | OAuth client secret, if the endpoint wants one. | +| `refresh_before` | duration (`30s`, `5m`, `1h`) | — | How long before expiry to refresh. Unset or unreadable: `5m`. | + + +The access token goes in the protocol's authorization header. To put it +somewhere else, write the header in `headers` with `{{access_token}}` where +the token goes: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +Outbound proxies. Different upstreams often need different ones, so there +is no global switch: an upstream picks one with `proxy`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name used in `providers[].proxy`. `direct` and `system` are built in. | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies. | +| `addr` | string | **required** | `host:port` of the proxy. | +| `auth` | object, [`proxies[].auth`](#cfg-proxies-auth) | — | User name and password, if the proxy wants them. | + + +#### `proxies[].auth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `user` | string | **required** | User name. | +| `pass` | string, `${VAR}` allowed | **required** | Password. | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail: fail` is the default because falling back silently sends a +request by a path you did not intend; you would believe you were on the +proxy while you were not. + +### `pricing` + +The cost of a request is its usage times the price of the model. Prices +come from the default price table (LiteLLM's public dataset, a copy of +which is built into the binary and refreshed daily) or from a price sheet +an upstream picks. A change of price applies to requests from then on, +never to ones already recorded. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `auto_update` | bool | `true` | Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline. | +| `sheets` | list of [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | Price sheets of your own. An upstream uses one with `providers[].pricing`. | + + +#### `pricing.sheets` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the sheet; unique. | +| `multiplier` | number | `1` | Applied to every price of the default table, cache and long-context prices included. | +| `models` | map of model id → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | Prices for single models. They replace the default table's price for that model and are not multiplied. | + + +#### `pricing.sheets[].models` + +Prices are in US dollars per million tokens, as printed on vendors' price +pages. Every field is written out; nothing is inferred when pricing. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `input` | number | **required** | US dollars per million input tokens. | +| `output` | number | **required** | US dollars per million output tokens. | +| `cache_read` | number | **required** | US dollars per million tokens read from the prompt cache. | +| `cache_write_5m` | number | **required** | US dollars per million tokens written to a 5-minute cache. | +| `cache_write_1h` | number | **required** | US dollars per million tokens written to a 1-hour cache. | +| `input_above_200k` | number | — | Input price once a request's input exceeds 200K tokens. Written together with `output_above_200k`, or neither. | +| `output_above_200k` | number | — | Output price once a request's input exceeds 200K tokens. | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +Some requests clients send are not the user's: connectivity checks, +warm-ups, session titles, topic detection, suggestions. Each class can be +answered locally (`intercept`, nothing is sent upstream), passed through +(`passthrough`), or handed to the routing rules (`route`, matched with +`when.intent`). The defaults intercept only what nobody would miss. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | Warm-up requests. Answered locally by default. | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | Requests that name a session. Passed through by default: intercepting them gives every session the same title. | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | Topic detection. Passed through by default. | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | Suggestions. Passed through by default. | + + +### `security` + +Five guards, applied to every upstream alike. Each has a `mode`: `off`, +`observe` (detect and record, change nothing) or `enforce` (act). They start +in `observe`, except the output limit, which starts `off`. What `enforce` +does differs per guard, and each says so below. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `redact` | object, [`security.redact`](#cfg-security-redact) | — | Outbound redaction: credentials found in a request are replaced before it leaves. | +| `inspect_tools` | object, [`security.inspect_tools`](#cfg-security-inspect_tools) | — | Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off. | +| `hidden_text` | object, [`security.hidden_text`](#cfg-security-hidden_text) | — | Hidden characters that people cannot see and models can read refuse the request. | +| `content` | object, [`security.content`](#cfg-security-content) | — | Content filter: words or patterns in what the caller sends refuse the request. | +| `output_limit` | object, [`security.output_limit`](#cfg-security-output_limit) | — | Output length: a response longer than the limit is cut off. | + + +#### `security.redact` + +Before a request leaves, credentials in it are looked for. Under `enforce` +they are replaced. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `custom` | list of [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | Rules of your own: whatever a pattern matches is treated as a credential. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Out of the box | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | on | +| `openai-project-key` | OpenAI project key | on | +| `openai-api-key` | OpenAI API key | on | +| `github-personal-token` | GitHub personal access token | on | +| `github-oauth-token` | GitHub OAuth token | on | +| `github-server-token` | GitHub server token | on | +| `github-user-token` | GitHub user token | on | +| `github-fine-grained-token` | GitHub fine-grained token | on | +| `slack-bot-token` | Slack bot token | on | +| `slack-user-token` | Slack user token | on | +| `slack-app-token` | Slack app token | on | +| `aws-access-key-id` | AWS access key ID | on | +| `aws-temporary-key-id` | AWS temporary access key ID | on | +| `google-api-key` | Google API key | on | +| `google-oauth-token` | Google OAuth token | on | +| `gitlab-token` | GitLab token | on | +| `stripe-live-key` | Stripe live key | on | +| `stripe-restricted-key` | Stripe restricted key | on | +| `npm-token` | npm token | on | +| `digitalocean-token` | DigitalOcean token | on | +| `sendgrid-key` | SendGrid key | on | +| `private-key` | Private key | on | +| `jwt` | JWT | on | +| `conn-string-password` | Connection string password | on | +| `internal-ip` | Internal IP address | off | +| `internal-domain` | Internal domain | off | + + +#### `security.inspect_tools` + +Tool calls a model returns are checked against the rules. Under `enforce`, +a match with rules set to `cut` stops the response, so the client never +receives a complete call to run. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `cut` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`). | +| `custom` | list of [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | Rules of your own, matched against the arguments of a tool call. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `action` | `cut` \| `record` | `record` | Under `enforce`: `cut` the response off, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Under `enforce`, out of the box | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +Characters people cannot see and models can read, in what the caller sends +(tool results included). Under `enforce`, the request is refused. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `disable` | list of strings | `[]` | Kinds not to look for: `tag`, `bidi`. | + + + +| Kind | What it is | +|---|---| +| `tag` | Unicode tag characters (U+E0000 to U+E007F): invisible everywhere, read by the model, able to carry a whole instruction. | +| `bidi` | Bidirectional control characters: make the order shown differ from the order the model reads. | + + +#### `security.content` + +Words or patterns in what the caller sends. Under `enforce`, a match with +rules set to `block` refuses the request. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `block` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting. | +| `custom` | list of [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | Rules of your own. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | A keyword, or a regular expression with `match: regex`. Case-insensitive either way. | +| `match` | `contains` \| `regex` | `contains` | `contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression. | +| `action` | `block` \| `record` | `record` | Under `enforce`: `block` the request, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Group | Out of the box | Under `enforce`, out of the box | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | on | `block` | +| `ignore-all-previous` | Ignore all previous | injection | on | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | on | `block` | +| `jailbreak` | Jailbreak | injection | off | `block` | +| `dan` | DAN | injection | off | `block` | +| `developer-mode` | Developer mode | injection | off | `block` | +| `you-are-now` | Persona manipulation | persona | off | `block` | +| `new-persona` | New persona | persona | off | `record` | +| `act-as` | Act as | persona | off | `record` | +| `pretend-to-be` | Pretend to be | persona | off | `record` | +| `system-prompt` | System prompt extraction | persona | off | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | off | `record` | +| `what-are-your-rules` | What are your rules | persona | off | `record` | +| `base64-wall` | Base64 smuggling | persona | off | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | off | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | off | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | off | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | off | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | off | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | off | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | off | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | off | `block` | + + +#### `security.output_limit` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | Off out of the box: no single limit suits every use. `observe` records long responses; `enforce` stops the stream at the limit. | +| `max_chars` | integer | `100000` | Limit in characters (Unicode scalar values), from 1 to 1000000. | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +Two limits, because the two kinds of data differ in size by three orders +of magnitude: request bodies are tens of kilobytes each, a request's record +a few hundred bytes. The byte limit covers bursts. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `body_days` | integer | `7` | Days to keep request and response bodies. | +| `row_days` | integer | `90` | Days to keep the record of each request (time, model, usage, cost). | +| `body_max_bytes` | integer | `2147483648` | Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 2 GiB. | + + +### `groups` + +A group puts several upstreams behind one name. Rules send requests to a +group with `to`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the group; unique, and not the name of an upstream. | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price. | +| `providers` | list of strings | **required** | Member upstreams, by name. | +| `session_affinity` | bool | `true` | Keep a session on the same upstream so its prompt cache keeps hitting. Turning it off under `load-balance` spreads every turn and loses the cache. | +| `selected` | string | — | For `select`: the chosen member. | + + +`fallback` is the default because spreading a session across upstreams +loses the prompt cache, which is worth far more than any spread of load on +a single user's machine. + +### `routes` + +A route is a list of rules evaluated top to bottom. Each key takes the route +named in its `route`, otherwise `default_route`, otherwise the route named +`default`; without any, requests fail over across all upstreams in the +order they are declared. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the route; unique. `default` is the one keys use unless told otherwise. | +| `rules` | list of [`routes[].rules[]`](#cfg-routes-rules) | `[]` | Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes. | + + +#### `routes[].rules` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the traffic view. | +| `when` | object, [`routes[].rules[].when`](#cfg-routes-rules-when) | — | Conditions, all of which have to hold. Unset: matches every request. | +| `to` | string | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`. | +| `set` | object, [`routes[].rules[].set`](#cfg-routes-rules-set) | — | Parameters to rewrite. Collected from every matching rule, not only the first. | +| `deny` | string | — | Refuse the request with this reason. | + + +#### `routes[].rules[].when` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Requested model, glob (`claude-opus-*`). | +| `client` | string | — | Name of the gateway key the request used, exactly. | +| `dialect` | string | — | API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`. | +| `input_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | Estimated input tokens. | +| `max_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | The request's `max_tokens`. A request without one never matches. | +| `tool_count` | comparison (`>200k`, `<=4k`, `==3`) | — | Number of tools offered. | +| `cache` | bool | — | Whether the request uses the prompt cache. | +| `tools` | bool | — | Whether the request offers tools. | +| `image` | bool | — | Whether the request contains an image. | +| `thinking` | bool | — | Whether extended thinking is on. | +| `stream` | bool | — | Whether the response is streamed. | +| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing. | +| `provider_would_be` | string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`. | + + +A comparison starts with `>`, `>=`, `<`, `<=` or `==`, and the number may +end in `k` or `m`: `">200k"`, `"<=4k"`. Without an operator it is an error, +not an equality: `"200k"` alone is refused. + +#### `routes[].rules[].set` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Send a different model. The prompt cache of the session is lost. | +| `max_tokens` | integer | — | Replace `max_tokens`. | +| `thinking` | bool | — | Turn extended thinking on or off. | +| `only_at_session_start` | bool | `false` | Apply only when a session starts. Recorded and shown; not in effect yet. | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: long context goes to the official API + when: { input_tokens: ">200k" } + to: anthropic + - name: titles go to the cheap model + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: everything else + to: fast +default_route: default +``` + +## Environment variables + +| Variable | Effect | +|---|---| +| `THINKWATCH_HOME` | Data directory, instead of `~/.thinkwatch` (`%APPDATA%\ThinkWatch` on Windows). | +| `TWCORE_LOG` | Log filter, in `tracing` syntax (`info`, `debug`, `tw_gateway=debug`). | +| `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY` | Used by upstreams with `proxy: system`. | +| Any other | Read where the configuration writes `${NAME}`. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md new file mode 100644 index 00000000..3fc0b67d --- /dev/null +++ b/docs/config.zh-CN.md @@ -0,0 +1,753 @@ +# 配置手册 + +[English](config.md) + +ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每个字段的作用、默认值、可选值,以及改动如何进入正在运行的进程。在服务器上运行 core、由桌面应用远程管理,见[在服务器上运行 core](server.zh-CN.md)。 + +本文的字段表由代码生成,与代码不一致时测试失败。表中列出的字段,就是程序实际读取的字段。 + +## 文件位置 + +| 平台 | 默认位置 | +|---|---| +| macOS、Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 + +没有配置文件时,`twcore serve` 会写入一份初始配置;`twcore init` 也可以按需生成。两者生成的内容如下: + +```yaml +version: 1 +clients: + - name: default + key: tw-… # 自动生成 +``` + +这已是一份完整、合法的配置。其中还没有上游,因此控制面照常运行,请求会得到「尚未配置上游」的错误。加上一个上游即可转发: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## 读取规则 + +- **本文没有的字段名一律是错误。**把 `port` 写成 `prot` 时,不会被悄悄忽略、让网关在默认端口上起来;整份配置被拒绝,错误信息指出那个字段。 +- **默认值不写进文件。**没写的字段取下表中的默认值。应用和命令行只在取值不同于默认值时才写入字段,因此文件里出现的都是有人做出的选择。 +- **`${VAR}` 读取环境变量**,适用于标注「可写 `${VAR}`」的字段:上游密钥、请求头的值、代理密码。取值来自 core 进程的环境,在发送请求时读取;变量未设置时,该上游的请求失败,错误信息指出变量名。在 systemd 下,这个环境就是 unit 的 `EnvironmentFile`。 +- **名字即引用。**规则、策略组、密钥按名字引用上游、策略组、路由和价目表。指向不存在的名字,在加载时就报错,而不是成为一条永不命中的规则。在应用里改名时,所有引用在同一次写入中一起修改。以 `__` 开头的名字保留给内置项。 +- **`version`** 是格式版本,目前为 `1`。版本更高的文件出自更新的 twcore,整份拒绝。 + +## 改动如何生效 + +修改配置有三种途径:桌面应用、`twcore config …` 命令、用编辑器直接修改文件。三者走同一条路径。core 监视配置文件,保存后一秒内重新加载,无需重启。 + +新版本要通过以下每一关才会换入: + +1. 能按 YAML 解析。 +2. 符合结构:字段名已知,类型正确。 +3. 自洽:名字不重复、引用都能找到、正则能编译、CIDR 写法正确。 +4. 能据此建立运行时对象。 + +任何一关失败,**原有配置继续服务**,错误信息说明失败在哪一关、哪个位置。写错一个字不会让网关停下。在保存出合法版本之前,桌面应用会一直显示这次拒绝。 + +`listen.gateway` 的改动同样即时生效:core 打开新的监听;打不开时(例如端口被占用)保留原监听并报告原因。保留期限的改动在下一次每小时的清理时生效。 + +两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 + +### 历史与回滚 + +每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 + +```sh +twcore check # 只校验配置,不启动任何服务 +twcore config show # 打印当前配置及其版本 +twcore config history # 列出历史版本,最新的在前 +twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore config set /listen/gateway/port 8790 --int +``` + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 + +`twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 + +### 网关写回的凭据 + +有一种写入不是由人发起的。上游的 OAuth token 端点换发新的 refresh token 后,旧的随即作废,因此网关会把新 token(以及 access token 和过期时间)写回 `providers[].oauth`。只改这几个值,注释和排版保持原样。 + +## 字段参考 + +每张表列出一节的全部字段。「默认值」一栏为「—」表示不写就没有这个字段,其含义见说明。 + +### 顶层 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `version` | 整数 | **必填** | 文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。 | +| `listen` | 对象,见 [`listen`](#cfg-listen) | — | 网关和控制通道在哪里监听。 | +| `clients` | 对象列表,见 [`clients[]`](#cfg-clients) | `[]` | 网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。 | +| `providers` | 对象列表,见 [`providers[]`](#cfg-providers) | `[]` | 上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。 | +| `proxies` | 对象列表,见 [`proxies[]`](#cfg-proxies) | `[]` | 出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。 | +| `pricing` | 对象,见 [`pricing`](#cfg-pricing) | — | 默认价目表是否定期刷新,以及自定义价目表。 | +| `client_probes` | 对象,见 [`client_probes`](#cfg-client_probes) | — | 客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。 | +| `security` | 对象,见 [`security`](#cfg-security) | — | 五项防护。出厂时都处在 `observe` 或 `off`,不改变、不拦截任何请求。 | +| `retention` | 对象,见 [`retention`](#cfg-retention) | — | 请求日志保留多久。 | +| `groups` | 对象列表,见 [`groups[]`](#cfg-groups) | `[]` | 策略组:多个上游合用一个名字,并规定如何在其中选择。 | +| `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | +| `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | +| `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | + + +### `listen` + +core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网关,以及桌面应用和 `twcore` 命令使用的控制通道。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `gateway` | 对象,见 [`listen.gateway`](#cfg-listen-gateway) | — | AI 网关,即客户端发送请求的地址。 | + + +#### `listen.gateway` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。 | +| `port` | 整数 | `8788` | 网关的 TCP 端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。 | + + +`bind` 超出本机范围时,由 `allow_from` 决定允许谁连接。网关不提供 TLS:只在可信的网络中开放,或放在隧道、VPN 之后。 + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +控制通道供桌面应用,以及同一台机器上的 `twcore config`、`twcore control-key` 与 core 通信。本地通道是数据目录中的 socket 文件(Windows 上是回环端口);除非启用 `remote`,控制通道不开任何网络端口。 + +每条控制连接,无论本地还是远程,都以一次握手开始,证明双方都持有 `key`(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`:密钥作为预共享密钥,每条连接协商新的会话密钥,通信内容加密)。不使用证书。不持有密钥的一方无法完成第一条握手消息。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `key` | 字符串 | 自动生成 | 控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。 | +| `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | + + +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 + +```sh +twcore control-key # 显示密钥,用于粘贴到桌面应用 +twcore control-key --rotate # 更换密钥;已连接的应用需要重新连接 +``` + +两条命令都在运行 core 的机器上执行。 + +#### `listen.control.remote` + +供另一台机器上的桌面应用连接的网络端口。它在本地通道之外额外开启,因此这里写错(端口被占用、`allow_from` 把自己挡在外面)也不会把本机锁在门外:`twcore config` 和本机应用照常可用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。 | +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `all` | 监听哪张网卡,写法同 `listen.gateway.bind`。 | +| `port` | 整数 | 自动生成 | TCP 端口。没有固定默认值:和密钥一样,生成这一节时写入一个随机端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 允许连接的来源,写法同 `listen.gateway.allow_from`。其他来源的连接在握手之前关闭,不回任何字节。 | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 个十六进制字符 + remote: + enabled: true + bind: all + port: 41327 # 随机生成,在生成这一节时写入 + allow_from: [192.168.1.0/24] +``` + +经远程端口的连接无论应用如何请求,都不能做三件事:关闭 core(它由 systemd 管理)、修改 `listen.control`(它进来的那扇门)、生成诊断包(诊断包会写在服务器上)。握手限时 5 秒;同一来源一分钟内握手失败 5 次,封禁一分钟。 + +### `clients` + +网关密钥,即 Claude Code、Codex 等客户端向网关发送的密钥。密钥即身份:并发上限、模型范围、路由都按密钥设置。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 密钥的名字,不能重复。路由规则用 `when.client` 匹配它。 | +| `key` | 字符串 | **必填** | 客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。 | +| `max_concurrent` | 整数 | — | 用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。 | +| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。 | +| `route` | 字符串 | — | 这把密钥的请求走哪条路由。不写:`default_route`。 | +| `client` | 字符串 | — | 这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。 | +| `disabled` | 布尔 | `false` | 拒绝使用这把密钥的所有请求,密钥本身保留。 | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +上游,即请求被转发到的接口。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。 | +| `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth` 同时写。 | +| `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | +| `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | 上游的接口格式。不写:官方地址按 `base_url` 识别,其余按 `anthropic` 处理。 | +| `proxy` | 字符串 | `direct` | `direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。 | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | 代理不可用时:请求失败(`fail`),或改为直连(`direct`)。 | +| `models` | 字符串列表 | `[]` | 上游不支持 `/v1/models` 时,按这份清单认定它提供的模型。 | +| `models_only` | 字符串列表 | — | 只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。 | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。 | +| `pricing` | 字符串 | — | `pricing.sheets` 中某张价目表的名字。不写:默认价目表。 | +| `disabled` | 布尔 | `false` | 不参与路由,模型也不出现在模型列表里;配置原样保留。 | + + +凭据有三种写法:`key`,放进协议规定的请求头;`oauth`,用 refresh token 换取 token;`headers`,用于上游自有的鉴权方式。`headers` 可以和前两者同时使用,但不能再设置已经承载凭据的那个请求头。 + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到的凭据,不能手写。不支持 Claude 和 Google 的订阅登录,请使用 API 密钥。 + +#### `providers[].oauth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `access` | 字符串 | — | 当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。 | +| `expires_at` | 字符串 | — | `access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。 | +| `refresh` | 字符串 | **必填** | Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。 | +| `endpoint` | 字符串 | **必填** | token 端点的地址。 | +| `client_id` | 字符串 | — | OAuth 客户端 ID,端点需要时填写。 | +| `client_secret` | 字符串 | — | OAuth 客户端密钥,端点需要时填写。 | +| `refresh_before` | 时长(`30s`、`5m`、`1h`) | — | 提前多久刷新。不写或写法无法识别:`5m`。 | + + +access token 默认放进协议的鉴权请求头。要放在别处,在 `headers` 中写出那个请求头,用 `{{access_token}}` 标出 token 的位置: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +出站代理。不同上游需要的代理往往不同,因此没有全局开关:由每个上游用 `proxy` 选择。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | `providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。 | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。 | +| `addr` | 字符串 | **必填** | 代理的 `host:port`。 | +| `auth` | 对象,见 [`proxies[].auth`](#cfg-proxies-auth) | — | 代理需要时填写用户名和密码。 | + + +#### `proxies[].auth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `user` | 字符串 | **必填** | 用户名。 | +| `pass` | 字符串,可写 `${VAR}` | **必填** | 密码。 | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail` 默认为 `fail`:静默改为直连会让请求走一条意料之外的路径,而使用者仍以为请求经过了代理。 + +### `pricing` + +一次请求的费用为用量乘以模型单价。单价来自默认价目表(LiteLLM 的公开数据集,程序内置一份,每天联网刷新),或来自上游选用的自定义价目表。单价变动只影响此后的请求,不改变已记录请求的费用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `auto_update` | 布尔 | `true` | 每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。 | +| `sheets` | 对象列表,见 [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | 自定义价目表。上游用 `providers[].pricing` 选用。 | + + +#### `pricing.sheets` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 价目表的名字,不能重复。 | +| `multiplier` | 数字 | `1` | 作用于默认价目表的全部单价,包括缓存和长上下文单价。 | +| `models` | 映射: 模型 ID → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | 单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。 | + + +#### `pricing.sheets[].models` + +单价以每百万 token 的美元计,与厂商价格页上的写法一致。每个字段都要写明,计价时不做任何推算。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `input` | 数字 | **必填** | 每百万输入 token 的美元价格。 | +| `output` | 数字 | **必填** | 每百万输出 token 的美元价格。 | +| `cache_read` | 数字 | **必填** | 每百万缓存读取 token 的美元价格。 | +| `cache_write_5m` | 数字 | **必填** | 每百万写入 5 分钟缓存 token 的美元价格。 | +| `cache_write_1h` | 数字 | **必填** | 每百万写入 1 小时缓存 token 的美元价格。 | +| `input_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写。 | +| `output_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输出单价。 | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类都可以在本地应答(`intercept`,不向上游发送任何内容)、原样放行(`passthrough`),或交给路由规则(`route`,由 `when.intent` 匹配)。默认只拦下拦了也不会少任何东西的那几类。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | 预热请求。默认在本地应答。 | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | 为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。 | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | 话题检测。默认放行。 | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | 建议。默认放行。 | + + +### `security` + +五项防护,对所有上游一视同仁。每一项都有 `mode`:`off`、`observe`(检测并记录,不改变任何行为)、`enforce`(处置)。出厂时除输出长度为 `off` 外,其余都是 `observe`。各项在 `enforce` 下的处置不同,分别见下文。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `redact` | 对象,见 [`security.redact`](#cfg-security-redact) | — | 出站脱敏:请求发出前,把其中的凭据替换掉。 | +| `inspect_tools` | 对象,见 [`security.inspect_tools`](#cfg-security-inspect_tools) | — | 工具调用审查:模型返回的工具调用中出现危险命令时切断响应。 | +| `hidden_text` | 对象,见 [`security.hidden_text`](#cfg-security-hidden_text) | — | 人看不见、模型读得到的隐藏字符,出现时拒绝请求。 | +| `content` | 对象,见 [`security.content`](#cfg-security-content) | — | 内容过滤:调用方发送的内容中出现指定的词或写法时拒绝请求。 | +| `output_limit` | 对象,见 [`security.output_limit`](#cfg-security-output_limit) | — | 输出长度:回答超过上限时切断。 | + + +#### `security.redact` + +请求发出前查找其中的凭据。`enforce` 下将其替换。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `custom` | 对象列表,见 [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | 自定义规则:正则匹配到的内容按凭据处理。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 出厂 | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | 开 | +| `openai-project-key` | OpenAI project key | 开 | +| `openai-api-key` | OpenAI API key | 开 | +| `github-personal-token` | GitHub personal access token | 开 | +| `github-oauth-token` | GitHub OAuth token | 开 | +| `github-server-token` | GitHub server token | 开 | +| `github-user-token` | GitHub user token | 开 | +| `github-fine-grained-token` | GitHub fine-grained token | 开 | +| `slack-bot-token` | Slack bot token | 开 | +| `slack-user-token` | Slack user token | 开 | +| `slack-app-token` | Slack app token | 开 | +| `aws-access-key-id` | AWS access key ID | 开 | +| `aws-temporary-key-id` | AWS temporary access key ID | 开 | +| `google-api-key` | Google API key | 开 | +| `google-oauth-token` | Google OAuth token | 开 | +| `gitlab-token` | GitLab token | 开 | +| `stripe-live-key` | Stripe live key | 开 | +| `stripe-restricted-key` | Stripe restricted key | 开 | +| `npm-token` | npm token | 开 | +| `digitalocean-token` | DigitalOcean token | 开 | +| `sendgrid-key` | SendGrid key | 开 | +| `private-key` | Private key | 开 | +| `jwt` | JWT | 开 | +| `conn-string-password` | Connection string password | 开 | +| `internal-ip` | Internal IP address | 关 | +| `internal-domain` | Internal domain | 关 | + + +#### `security.inspect_tools` + +按规则检查模型返回的工具调用。`enforce` 下命中处置为 `cut` 的规则时切断响应,客户端拿不到可执行的完整调用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `cut` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。 | +| `custom` | 对象列表,见 [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | 自定义规则,按工具调用的参数匹配。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `action` | `cut` \| `record` | `record` | `enforce` 下切断响应(`cut`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | `enforce` 下出厂处置 | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +调用方发送的内容中(包括工具结果)人看不见、模型读得到的字符。`enforce` 下拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `disable` | 字符串列表 | `[]` | 不检查的种类:`tag`、`bidi`。 | + + + +| 种类 | 说明 | +|---|---| +| `tag` | Unicode 标签字符(U+E0000 至 U+E007F):在任何地方都不可见,模型却能读到,足以藏下一整段指令。 | +| `bidi` | 双向控制符:使显示顺序与模型读到的顺序不一致。 | + + +#### `security.content` + +调用方发送的内容中出现的词或写法。`enforce` 下命中处置为 `block` 的规则时拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `block` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的。 | +| `custom` | 对象列表,见 [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | 自定义规则。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 关键词;`match: regex` 时为正则表达式。均不区分大小写。 | +| `match` | `contains` \| `regex` | `contains` | `contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。 | +| `action` | `block` \| `record` | `record` | `enforce` 下拒绝请求(`block`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | 开 | `block` | +| `ignore-all-previous` | Ignore all previous | injection | 开 | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | 开 | `block` | +| `jailbreak` | Jailbreak | injection | 关 | `block` | +| `dan` | DAN | injection | 关 | `block` | +| `developer-mode` | Developer mode | injection | 关 | `block` | +| `you-are-now` | Persona manipulation | persona | 关 | `block` | +| `new-persona` | New persona | persona | 关 | `record` | +| `act-as` | Act as | persona | 关 | `record` | +| `pretend-to-be` | Pretend to be | persona | 关 | `record` | +| `system-prompt` | System prompt extraction | persona | 关 | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | 关 | `record` | +| `what-are-your-rules` | What are your rules | persona | 关 | `record` | +| `base64-wall` | Base64 smuggling | persona | 关 | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | 关 | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | 关 | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | 关 | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | 关 | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | 关 | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | 关 | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | 关 | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | 关 | `block` | + + +#### `security.output_limit` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | 出厂关闭:没有一个上限适合所有用途。`observe` 记录超长的回答;`enforce` 在超过上限处停止输出。 | +| `max_chars` | 整数 | `100000` | 上限,按字符(Unicode 标量)计,取值 1 到 1000000。 | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +设两个期限,是因为两类数据的体积相差三个数量级:一条请求的正文有几十 KB,一条请求记录只有几百字节。字节上限用于应对用量突增。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `body_days` | 整数 | `7` | 请求和响应正文保留的天数。 | +| `row_days` | 整数 | `90` | 每条请求记录(时间、模型、用量、费用)保留的天数。 | +| `body_max_bytes` | 整数 | `2147483648` | 正文最多占用的字节数,超出时从最早的日期开始删除。默认 2 GiB。 | + + +### `groups` + +策略组让多个上游合用一个名字。规则用 `to` 把请求交给策略组。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。 | +| `providers` | 字符串列表 | **必填** | 成员上游的名字。 | +| `session_affinity` | 布尔 | `true` | 同一会话固定走同一家,使 prompt cache 持续命中。在 `load-balance` 下关闭会让每一轮都换一家,缓存随之失效。 | +| `selected` | 字符串 | — | `select` 类型选中的成员。 | + + +默认类型为 `fallback`:把一个会话分散到多家上游会丢掉 prompt cache,而在单个使用者的机器上,分散负载换来的远不及缓存省下的。 + +### `routes` + +一条路由是一组自上而下求值的规则。每把密钥使用其 `route` 指定的路由;没有指定时用 `default_route`;再没有时用名为 `default` 的路由;一条路由都没有时,请求按上游的声明顺序故障转移。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 路由的名字,不能重复。`default` 是密钥默认使用的那条。 | +| `rules` | 对象列表,见 [`routes[].rules[]`](#cfg-routes-rules) | `[]` | 自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。 | + + +#### `routes[].rules` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和流量详情中显示的名字。 | +| `when` | 对象,见 [`routes[].rules[].when`](#cfg-routes-rules-when) | — | 条件,须全部满足。不写:匹配所有请求。 | +| `to` | 字符串 | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。 | +| `set` | 对象,见 [`routes[].rules[].set`](#cfg-routes-rules-set) | — | 改写请求参数。从所有匹配的规则累积,不只第一条。 | +| `deny` | 字符串 | — | 以这句原因拒绝请求。 | + + +#### `routes[].rules[].when` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。 | +| `client` | 字符串 | — | 请求所用网关密钥的名字,精确匹配。 | +| `dialect` | 字符串 | — | 客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。 | +| `input_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 估算的输入 token 数。 | +| `max_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中的 `max_tokens`。未写该参数的请求不匹配。 | +| `tool_count` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中提供的工具数量。 | +| `cache` | 布尔 | — | 请求是否使用 prompt cache。 | +| `tools` | 布尔 | — | 请求是否带工具。 | +| `image` | 布尔 | — | 请求是否包含图片。 | +| `thinking` | 布尔 | — | 是否开启扩展思考。 | +| `stream` | 布尔 | — | 是否流式返回。 | +| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。 | +| `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 | + + +比较式以 `>`、`>=`、`<`、`<=` 或 `==` 开头,数字可以带 `k` 或 `m` 后缀:`">200k"`、`"<=4k"`。不带运算符是错误,不当作相等:单写 `"200k"` 会被拒绝。 + +#### `routes[].rules[].set` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 换成另一个模型发送。该会话的 prompt cache 随之失效。 | +| `max_tokens` | 整数 | — | 替换 `max_tokens`。 | +| `thinking` | 布尔 | — | 开启或关闭扩展思考。 | +| `only_at_session_start` | 布尔 | `false` | 只在会话开始时应用。目前只记录和显示,尚未生效。 | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: 长上下文走官方 + when: { input_tokens: ">200k" } + to: anthropic + - name: 标题用便宜模型 + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: 其余 + to: fast +default_route: default +``` + +## 环境变量 + +| 变量 | 作用 | +|---|---| +| `THINKWATCH_HOME` | 数据目录,替代 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`)。 | +| `TWCORE_LOG` | 日志过滤,`tracing` 语法(`info`、`debug`、`tw_gateway=debug`)。 | +| `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY` | 供 `proxy: system` 的上游使用。 | +| 其他 | 配置中写 `${NAME}` 的位置读取。 | From e20e47690d9ee8a8c8aa550eb112cc6c1df7fec0 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:41:11 +0800 Subject: [PATCH 4/5] test(config): compare the manual regardless of line endings A Windows checkout can turn the manuals' line endings into CRLF, and the generated blocks are rendered with LF. Co-Authored-By: Claude Opus 5.5 --- crates/tw-config/tests/manual.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs index 7a30669b..ddd1216d 100644 --- a/crates/tw-config/tests/manual.rs +++ b/crates/tw-config/tests/manual.rs @@ -547,7 +547,10 @@ fn the_manual_is_what_the_code_says() { ("docs/config.zh-CN.md", Lang::Zh), ] { let path = workspace().join(file); - let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{file}: {e}")); + // Windows 上的检出可能把换行转成了 CRLF,比的是内容不是换行 + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{file}: {e}")) + .replace("\r\n", "\n"); let (next, used) = match regenerate(&text, l, &all) { Ok(x) => x, Err(e) => { @@ -607,7 +610,9 @@ fn the_examples_in_the_manual_parse() { "docs/server.md", "docs/server.zh-CN.md", ] { - let text = std::fs::read_to_string(workspace().join(file)).unwrap(); + let text = std::fs::read_to_string(workspace().join(file)) + .unwrap() + .replace("\r\n", "\n"); let mut rest = text.as_str(); while let Some(i) = rest.find("```yaml\n") { let body = &rest[i + 8..]; From c29aff0d800df03b3aee54a7fbd633b7d2c95ba1 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:02:59 +0800 Subject: [PATCH 5/5] docs(config): check listen.control against the code now that it exists The control key landed (#184), so `listen.control` is no longer pending: its table is checked against `ControlListen`, `listen` gains the `control` row, and the starting configuration shown in the manual has the key `init` and `serve` now write. `listen.control.remote` is still pending. A row that points at a pending section is left out of its parent's field comparison, so `remote` stays documented under `listen.control`; the moment `ControlListen` reads `remote`, the pending probe fails and asks for the real type. Co-Authored-By: Claude Opus 5.5 --- crates/tw-config/tests/manual.rs | 15 ++++++++++- crates/tw-config/tests/manual/schema.rs | 33 ++++++++++++++++--------- docs/config.md | 4 +++ docs/config.zh-CN.md | 4 +++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs index ddd1216d..649208f0 100644 --- a/crates/tw-config/tests/manual.rs +++ b/crates/tw-config/tests/manual.rs @@ -371,7 +371,20 @@ fn check_section(s: &Section, all: &[Section], errs: &mut Vec) { minimal, } => { let code: BTreeSet<&str> = fields().into_iter().collect(); - let declared: BTreeSet<&str> = s.rows.iter().map(|r| r.name).collect(); + // 指向一节还没进代码的对象的那一行,同样还没进代码:它由那一节的 + // `Ty::Pending` 看着,这里不数它 + let pending = |r: &Row| match r.kind { + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => all + .iter() + .any(|x| x.path == p && matches!(x.ty, Ty::Pending { .. })), + _ => false, + }; + let declared: BTreeSet<&str> = s + .rows + .iter() + .filter(|r| !pending(r)) + .map(|r| r.name) + .collect(); for f in code.difference(&declared) { errs.push(format!( "`{}.{f}` is in the code but not in the manual: add a row for it in \ diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index b4ad9565..e106db67 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -213,15 +213,26 @@ pub fn sections() -> Vec
{ Section { path: "listen", ty: checked!(Listen, "{}"), - rows: vec![row( - "gateway", - Kind::Obj("listen.gateway"), - Def::Section, - t( - "The AI gateway: the address clients send requests to.", - "AI 网关,即客户端发送请求的地址。", - ), - )], + rows: vec![ + row( + "gateway", + Kind::Obj("listen.gateway"), + Def::Section, + t( + "The AI gateway: the address clients send requests to.", + "AI 网关,即客户端发送请求的地址。", + ), + ), + row( + "control", + Kind::Obj("listen.control"), + Def::Section, + t( + "The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it.", + "控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。", + ), + ), + ], }, Section { path: "listen.gateway", @@ -255,9 +266,7 @@ pub fn sections() -> Vec
{ }, Section { path: "listen.control", - ty: Ty::Pending { - probe: "version: 1\nlisten:\n control: {}\n", - }, + ty: checked!(ControlListen, "{}"), rows: vec![ row( "key", diff --git a/docs/config.md b/docs/config.md index f87757e3..256bc736 100644 --- a/docs/config.md +++ b/docs/config.md @@ -30,6 +30,9 @@ control socket (`twcore.sock`; on Windows a loopback port recorded in ```yaml version: 1 +listen: + control: + key: 6629…753d # generated clients: - name: default key: tw-… # generated @@ -167,6 +170,7 @@ clients send requests to, and the control channel the desktop app and | Field | Type | Default | Description | |---|---|---|---| | `gateway` | object, [`listen.gateway`](#cfg-listen-gateway) | — | The AI gateway: the address clients send requests to. | +| `control` | object, [`listen.control`](#cfg-listen-control) | — | The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it. | #### `listen.gateway` diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 3fc0b67d..99796f58 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -19,6 +19,9 @@ ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每 ```yaml version: 1 +listen: + control: + key: 6629…753d # 自动生成 clients: - name: default key: tw-… # 自动生成 @@ -114,6 +117,7 @@ core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `gateway` | 对象,见 [`listen.gateway`](#cfg-listen-gateway) | — | AI 网关,即客户端发送请求的地址。 | +| `control` | 对象,见 [`listen.control`](#cfg-listen-control) | — | 控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。 | #### `listen.gateway`