diff --git a/Cargo.lock b/Cargo.lock
index 992d9cd..22901a0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1935,6 +1935,7 @@ dependencies = [
"rand 0.9.4",
"serde",
"serde_json",
+ "tempfile",
"tokio",
]
diff --git a/README.md b/README.md
index c9e2ab0..4ddc066 100644
--- a/README.md
+++ b/README.md
@@ -191,7 +191,7 @@ The same full-featured UI that runs in the desktop app is also available as a **
```shell
$ hk serve
-HarnessKit Web UI running at http://127.0.0.1:7070
+HarnessKit Web UI [my-host] running at http://127.0.0.1:7070/?token=a1b2c3…
```
This makes HarnessKit usable on **Linux servers**, **HPC clusters**, or any **headless machine** where a desktop app isn't an option. Web mode has **full feature parity** with the desktop app — the only difference is that file-system operations (like "Open in Finder") are desktop-only. See [Getting Started](#getting-started) for setup instructions.
@@ -255,7 +255,7 @@ Already installed? Open **Settings → Check for Updates** to upgrade in-app.
hk serve
```
- Then open `http://localhost:7070` in your browser.
+ Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. On a trusted single-user machine, `hk serve --no-token` skips the token entirely.
#### Remote server
@@ -282,7 +282,7 @@ Already installed? Open **Settings → Check for Updates** to upgrade in-app.
hk serve
```
- Then open `http://localhost:7070` in your local browser. Keep the SSH session running while you use HarnessKit.
+ Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints, in your local browser. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. Keep the SSH session running while you use HarnessKit.
> **Tip:** Managing several remote nodes? Start each with `hk serve --name
@@ -324,7 +324,7 @@ Download the binary for your platform from the [latest release](https://github.c
hk serve
```
- Then open `http://localhost:7070` in your browser.
+ Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. On a trusted single-user machine, `hk serve --no-token` skips the token entirely.
**Remote server:**
@@ -346,7 +346,7 @@ Download the binary for your platform from the [latest release](https://github.c
hk serve
```
- Then open `http://localhost:7070` in your local browser. Keep the SSH session running while you use HarnessKit.
+ Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints, in your local browser. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. Keep the SSH session running while you use HarnessKit.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 9a1f48f..a630802 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -191,7 +191,7 @@ $ hk disable --pack owner/repo # 按来源批量禁用
```shell
$ hk serve
-HarnessKit Web UI running at http://127.0.0.1:7070
+HarnessKit Web UI [my-host] running at http://127.0.0.1:7070/?token=a1b2c3…
```
这意味着 **Linux 服务器**、**HPC 集群** 或任何 **无图形界面的机器** 上也能运行 HarnessKit —— 这些都是桌面应用难以触达的场景。Web 模式的功能与桌面版完全一致,仅少数与系统文件管理器相关的操作(如「在访达中打开」)需要桌面版。安装步骤见 [快速开始](#快速开始)。
@@ -255,7 +255,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070
hk serve
```
- 然后在浏览器打开 `http://localhost:7070`。
+ 然后打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。可信的单用户机器上,用 `hk serve --no-token` 可完全跳过 token。
#### 远程服务器
@@ -282,7 +282,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070
hk serve
```
- 然后在本地浏览器打开 `http://localhost:7070`。使用 HarnessKit 期间请保持该 SSH 会话开启。
+ 然后在本地浏览器打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。使用 HarnessKit 期间请保持该 SSH 会话开启。
> **提示:** 管理多个远程节点时,用 `hk serve --name <标签>` 启动(如 `--name my-macbook`)。标签会显示在侧边栏和浏览器标签页标题里,多个 tab 一眼就能区分。默认取机器主机名。
@@ -324,7 +324,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070
hk serve
```
- 然后在浏览器打开 `http://localhost:7070`。
+ 然后打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。可信的单用户机器上,用 `hk serve --no-token` 可完全跳过 token。
**远程服务器:**
@@ -346,7 +346,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070
hk serve
```
- 然后在本地浏览器打开 `http://localhost:7070`。使用 HarnessKit 期间请保持该 SSH 会话开启。
+ 然后在本地浏览器打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。使用 HarnessKit 期间请保持该 SSH 会话开启。
diff --git a/crates/hk-cli/Cargo.toml b/crates/hk-cli/Cargo.toml
index e729901..b413f31 100644
--- a/crates/hk-cli/Cargo.toml
+++ b/crates/hk-cli/Cargo.toml
@@ -19,3 +19,6 @@ dirs = "6"
hk-web = { path = "../hk-web" }
tokio = { version = "1", features = ["full"] }
rand = "0.9"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/hk-cli/src/main.rs b/crates/hk-cli/src/main.rs
index 4127691..181db72 100644
--- a/crates/hk-cli/src/main.rs
+++ b/crates/hk-cli/src/main.rs
@@ -86,10 +86,19 @@ enum Commands {
#[arg(long, default_value = "127.0.0.1")]
host: String,
- /// Access token (auto-generated for non-localhost binds if omitted)
+ /// Access token. If omitted, a persistent token is auto-generated and
+ /// stored at ~/.harnesskit/web-token (mode 0600). Prefer this over
+ /// passing --token on shared hosts: command-line args are visible to
+ /// other users via `ps`/`/proc//cmdline`.
#[arg(long)]
token: Option,
+ /// Disable authentication entirely (no token). Only safe on a trusted
+ /// single-user machine — on a shared host (e.g. an HPC login node)
+ /// loopback is not isolated per-user, so any local user could connect.
+ #[arg(long)]
+ no_token: bool,
+
/// Node label shown in the web UI (defaults to the machine hostname).
/// Useful when opening multiple tabs against different remote nodes.
#[arg(long)]
@@ -100,16 +109,8 @@ enum Commands {
fn main() -> Result<()> {
let cli = Cli::parse();
- if let Commands::Serve { port, host, token, name } = cli.command {
- let effective_token = if host != "127.0.0.1" {
- Some(token.unwrap_or_else(|| {
- use rand::Rng;
- let token_value: u128 = rand::rng().random();
- format!("{token_value:032x}")
- }))
- } else {
- token
- };
+ if let Commands::Serve { port, host, token, no_token, name } = cli.command {
+ let effective_token = resolve_serve_token(token, no_token);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(hk_web::serve(hk_web::ServeOptions {
@@ -182,6 +183,99 @@ fn hk_data_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".harnesskit")
}
+/// Resolve the web access token for `hk serve`.
+///
+/// Secure-by-default: with neither `--token` nor `--no-token`, a persistent
+/// token is loaded (or generated) from `~/.harnesskit/web-token` and enforced
+/// even for `127.0.0.1` binds. On a shared host (HPC login node) the loopback
+/// interface is not isolated per-user, so binding `127.0.0.1` alone does not
+/// keep other local users out — only the token does.
+fn resolve_serve_token(explicit: Option, no_token: bool) -> Option {
+ if no_token {
+ return None;
+ }
+ if let Some(token) = explicit {
+ return Some(token);
+ }
+ Some(load_or_create_token())
+}
+
+/// Generate a 128-bit random token rendered as 32 hex chars.
+fn generate_token() -> String {
+ use rand::Rng;
+ let token_value: u128 = rand::rng().random();
+ format!("{token_value:032x}")
+}
+
+/// Load the persisted token from `~/.harnesskit/web-token`, or create one.
+///
+/// The file is created/rewritten with mode `0600` (owner-only). An existing
+/// file is reused only if it is non-empty and owner-only — if its permissions
+/// are looser than `0600` (e.g. left world-readable by an older version or a
+/// hostile pre-creation), the token is treated as compromised and regenerated.
+/// Falls back to an in-memory token if the file cannot be persisted.
+fn load_or_create_token() -> String {
+ let path = hk_data_dir().join("web-token");
+
+ if let Some(token) = read_token_if_secure(&path) {
+ return token;
+ }
+
+ let token = generate_token();
+ if let Err(err) = write_token_0600(&path, &token) {
+ eprintln!(
+ "warning: could not persist web token to {}: {err}. Using a one-time token for this run.",
+ path.display()
+ );
+ }
+ token
+}
+
+/// Return the stored token only if the file exists, is non-empty, and (on Unix)
+/// is owner-only (`0600`). Returns `None` otherwise so the caller regenerates.
+fn read_token_if_secure(path: &std::path::Path) -> Option {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ // Reject anything readable/writable by group or others.
+ let metadata = std::fs::metadata(path).ok()?;
+ if metadata.permissions().mode() & 0o077 != 0 {
+ return None;
+ }
+ }
+ let token = std::fs::read_to_string(path).ok()?;
+ let token = token.trim().to_string();
+ if token.is_empty() { None } else { Some(token) }
+}
+
+/// Write the token with owner-only permissions, creating the data dir first.
+fn write_token_0600(path: &std::path::Path, token: &str) -> std::io::Result<()> {
+ use std::io::Write;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ let mut options = std::fs::OpenOptions::new();
+ options.write(true).create(true).truncate(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let mut file = options.open(path)?;
+
+ // `OpenOptions::mode` is ignored when the file already exists, so set the
+ // permissions explicitly to harden a pre-existing, looser-permissioned file.
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
+ }
+
+ file.write_all(token.as_bytes())?;
+ Ok(())
+}
+
/// Build a grouping key matching the desktop's `extensionGroupKey`:
/// `kind \0 name \0 origin \0 developer`
/// For hooks, strip event/matcher prefix and keep only the command part.
@@ -508,3 +602,58 @@ fn format_score(score: u8) -> String {
TrustTier::NeedsReview => format!("{score}").truecolor(255, 165, 0).to_string(),
}
}
+
+#[cfg(test)]
+mod token_persistence_tests {
+ use super::{read_token_if_secure, write_token_0600};
+
+ /// Write persists the token and read reads it back; on Unix the file is
+ /// owner-only (0600).
+ #[test]
+ fn write_persists_token_owner_only() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("web-token");
+
+ write_token_0600(&path, "deadbeef").unwrap();
+
+ assert_eq!(read_token_if_secure(&path).as_deref(), Some("deadbeef"));
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(&path).unwrap().permissions().mode();
+ assert_eq!(mode & 0o777, 0o600, "token file must be owner-only");
+ }
+ }
+
+ /// A token file readable by group or others is refused, so the caller
+ /// regenerates rather than trusting a token other users could have read.
+ #[cfg(unix)]
+ #[test]
+ fn read_rejects_group_or_world_readable_token() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("web-token");
+ std::fs::write(&path, "deadbeef").unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
+
+ assert_eq!(read_token_if_secure(&path), None);
+ }
+
+ /// Overwriting an existing token file resets its permissions to owner-only,
+ /// even if it was previously group/world-readable.
+ #[cfg(unix)]
+ #[test]
+ fn write_rehardens_preexisting_loose_file() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("web-token");
+ std::fs::write(&path, "old").unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
+
+ write_token_0600(&path, "new").unwrap();
+
+ let mode = std::fs::metadata(&path).unwrap().permissions().mode();
+ assert_eq!(mode & 0o777, 0o600);
+ assert_eq!(read_token_if_secure(&path).as_deref(), Some("new"));
+ }
+}
diff --git a/crates/hk-web/src/lib.rs b/crates/hk-web/src/lib.rs
index 5ee11a8..e9ef307 100644
--- a/crates/hk-web/src/lib.rs
+++ b/crates/hk-web/src/lib.rs
@@ -66,9 +66,9 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> {
let app = router::build_router(state);
let addr: SocketAddr = format!("{}:{}", options.host, options.port).parse()?;
- // When auth is enabled (non-localhost binds), embed the token in the URL so
- // the user can paste a single link and be logged in — the frontend reads it
- // and strips it from the address bar. Mirrors Jupyter's `?token=` flow.
+ // When auth is enabled, embed the token in the URL so the user can paste a
+ // single link and be logged in — the frontend reads it and strips it from
+ // the address bar. Mirrors Jupyter's `?token=` flow.
let token_query = options
.token
.as_deref()
@@ -77,7 +77,7 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> {
match options.host.as_str() {
"127.0.0.1" => {
- eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}");
+ eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}{token_query}");
eprintln!("Access via SSH tunnel: ssh -L {p}:localhost:{p} your-server", p = options.port);
}
// 0.0.0.0 binds every interface but is not itself a reachable address,
diff --git a/src/lib/__tests__/transport.test.ts b/src/lib/__tests__/transport.test.ts
index 089a30d..86584fd 100644
--- a/src/lib/__tests__/transport.test.ts
+++ b/src/lib/__tests__/transport.test.ts
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { consumeUrlToken, getAuthToken } from "@/lib/transport";
beforeEach(() => {
- sessionStorage.clear();
+ localStorage.clear();
// Reset the URL between tests (jsdom keeps the last replaceState value).
window.history.replaceState({}, "", "/");
});
@@ -14,7 +14,7 @@ describe("consumeUrlToken", () => {
consumeUrlToken();
expect(getAuthToken()).toBe("abc123");
- expect(sessionStorage.getItem("hk_token")).toBe("abc123");
+ expect(localStorage.getItem("hk_token")).toBe("abc123");
expect(window.location.search).toBe("");
});
@@ -23,7 +23,7 @@ describe("consumeUrlToken", () => {
consumeUrlToken();
- expect(sessionStorage.getItem("hk_token")).toBe("abc123");
+ expect(localStorage.getItem("hk_token")).toBe("abc123");
expect(window.location.search).toBe("?scope=all");
});
@@ -32,7 +32,7 @@ describe("consumeUrlToken", () => {
consumeUrlToken();
- expect(sessionStorage.getItem("hk_token")).toBeNull();
+ expect(localStorage.getItem("hk_token")).toBeNull();
expect(window.location.search).toBe("?scope=all");
});
});
diff --git a/src/lib/transport.ts b/src/lib/transport.ts
index a205c12..c0ba68f 100644
--- a/src/lib/transport.ts
+++ b/src/lib/transport.ts
@@ -37,12 +37,14 @@ let authToken: string | null = null;
export function setAuthToken(token: string): void {
authToken = token;
- sessionStorage.setItem("hk_token", token);
+ // localStorage (not sessionStorage) so the token survives across new tabs
+ // and reloads — the user only has to open the `?token=` URL once.
+ localStorage.setItem("hk_token", token);
}
export function getAuthToken(): string | null {
if (authToken) return authToken;
- authToken = sessionStorage.getItem("hk_token");
+ authToken = localStorage.getItem("hk_token");
return authToken;
}