Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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.

> <sub>**Tip:** Managing several remote nodes? Start each with `hk serve --name <label>` (e.g. `--name my-macbook`). The label shows in the sidebar and the browser tab title, so multiple tabs are easy to tell apart. Defaults to the machine hostname.</sub>

Expand Down Expand Up @@ -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:**

Expand All @@ -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.

</details>

Expand Down
10 changes: 5 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 模式的功能与桌面版完全一致,仅少数与系统文件管理器相关的操作(如「在访达中打开」)需要桌面版。安装步骤见 [快速开始](#快速开始)。
Expand Down Expand Up @@ -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

#### 远程服务器

Expand All @@ -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 会话开启。

> <sub>**提示:** 管理多个远程节点时,用 `hk serve --name <标签>` 启动(如 `--name my-macbook`)。标签会显示在侧边栏和浏览器标签页标题里,多个 tab 一眼就能区分。默认取机器主机名。</sub>

Expand Down Expand Up @@ -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

**远程服务器:**

Expand All @@ -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 会话开启。

</details>

Expand Down
3 changes: 3 additions & 0 deletions crates/hk-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ dirs = "6"
hk-web = { path = "../hk-web" }
tokio = { version = "1", features = ["full"] }
rand = "0.9"

[dev-dependencies]
tempfile = "3"
171 changes: 160 additions & 11 deletions crates/hk-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/cmdline`.
#[arg(long)]
token: Option<String>,

/// 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)]
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String>, no_token: bool) -> Option<String> {
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<String> {
#[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.
Expand Down Expand Up @@ -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"));
}
}
8 changes: 4 additions & 4 deletions crates/hk-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/lib/__tests__/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}, "", "/");
});
Expand All @@ -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("");
});

Expand All @@ -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");
});

Expand 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");
});
});
6 changes: 4 additions & 2 deletions src/lib/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading