Merge pull request #8059 from SivanCola/feature/web-cli-handoff

feat(cli): add first-class Web handoff / 增加 CLI Web 会话交接
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 03:10:55 +08:00
commit 303139c612
30 files changed
+1719 -162

No files matched your search

+31 -13
View File
@@ -14,7 +14,7 @@
- [Configuration](#configuration)
- [CLI reference](./CLI.md)
- [Environment variables](#environment-variables)
- [Serve web frontend](#serve-web-frontend)
- [Web frontend](#web-frontend)
- [Configuration paths](./CONFIG_PATHS.md)
- [Reasoning language](./REASONING_LANGUAGE.md)
- [Task contracts and pause policy](./TASK_CONTRACT.md)
@@ -205,23 +205,39 @@ these separately reviewed reports. Runtime fatal throws, operating-system kills,
and panics in unwrapped background goroutines cannot be recovered by Go and do
not produce this local report.
## Serve web frontend
## Web frontend
`reasonix serve` starts the same local engine behind a browser UI. Use it when
you want a desktop-style surface without installing the desktop app, when running
Reasonix on a remote development box through a tunnel, or when you want a
shareable view of a live session.
For local use, `reasonix web` starts the browser UI and opens it in your default
browser. Inside an interactive CLI session, `/web` snapshots the current session,
restores the terminal, and opens an explicit `/sessions/<id>#token=...` deep link.
Even a never-used session keeps its reserved ID without forcing an empty
transcript onto disk, so the first Web turn continues the same session identity.
```bash
cd your-project
reasonix serve
# open http://127.0.0.1:8787
reasonix web
```
By default it listens on `127.0.0.1:8787` with `auth_mode = "none"`. Keep that
default for local-only use. If you bind outside loopback, expose it through a
tunnel, or put it behind a reverse proxy, enable authentication before sharing
the URL:
Use `reasonix web --no-open` when you want to start the foreground Web server
and print its URL without opening a browser tab. The lower-level
`reasonix serve` command starts the same engine without opening a browser by
default. It remains the right entry point for remote development boxes,
supervisors, tunnels, reverse proxies, and shareable authenticated sessions.
`reasonix web` starts at `127.0.0.1:8787`, automatically tries 8788, 8789, and
so on when a port is busy (up to 100 retries), and defaults to a newly generated
token even when `[serve].auth_mode` is `none`. Each live process registers a
single-writer heartbeat file under `<Reasonix home>/server/instances/`; clean
shutdown removes its own file, while later instances lazily remove records whose
owner process is confirmed dead. Multiple Web instances can therefore share one
Reasonix home without overwriting registry state. The process stays attached to
the terminal; stop it with Ctrl-C.
An explicit `reasonix web --auth none` disables the default token and should be
used only when the listener is intentionally trusted. `reasonix serve` keeps its
backward-compatible, config-driven `auth_mode = "none"` default on
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
or put it behind a reverse proxy, enable authentication before sharing the URL:
```bash
reasonix serve --auth token
@@ -229,7 +245,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
reasonix serve --auth password --password 'temporary-password'
```
Token mode prints a share URL with `?token=...`; pass `--token` or set
Token mode prints a share URL with `#token=...`; the Web page exchanges the
fragment for an HttpOnly cookie before starting API or SSE requests, keeping the
token out of request URLs, browser history, referrers, and access logs. Pass `--token` or set
`[serve].token` to reuse a stable token. Password mode requires either
`--password` at startup or a stored bcrypt hash:
+25 -9
View File
@@ -14,7 +14,7 @@
- [配置](#配置)
- [CLI 命令参考](./CLI.zh-CN.md)
- [环境变量](#环境变量)
- [Serve Web 前端](#serve-web-前端)
- [Web 前端](#web-前端)
- [配置路径](./CONFIG_PATHS.zh-CN.md)
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
@@ -184,19 +184,33 @@ reasonix report delete [ID] # 不发送,直接删除
需要单独审阅的报告。Go 无法恢复 runtime fatal throw、操作系统强制终止,以及未包装
后台 goroutine 中的 panic,因此这些情况不会生成本地报告。
## Serve Web 前端
## Web 前端
`reasonix serve` 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、
在远程开发机上通过 tunnel 使用,或把当前会话临时共享给浏览器查看的场景。
本机使用时,`reasonix web` 会启动浏览器 UI,并自动用默认浏览器打开。也可以在 CLI 交互会话中
执行 `/web`:Reasonix 会保存当前会话、恢复终端,然后打开明确的
`/sessions/<id>#token=...` 深链。即使会话尚未产生第一轮消息,也会延续已预留的 Session ID,
同时继续保持“空会话不提前写 transcript”的惰性落盘行为。
```bash
cd your-project
reasonix serve
# 打开 http://127.0.0.1:8787
reasonix web
```
默认监听 `127.0.0.1:8787`,认证模式是 `auth_mode = "none"`。这个默认值只适合本机使用。
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
如果想启动前台 Web 服务并打印地址、但不自动新开浏览器标签页,可使用
`reasonix web --no-open`。底层的 `reasonix serve`
默认不会打开浏览器,继续用于远程开发机、进程托管、tunnel、反向代理和需要认证分享的场景。
`reasonix web` 从 `127.0.0.1:8787` 开始监听;端口占用时会依次尝试 8788、8789……,
最多递增重试 100 次。它默认启用自动生成的 Token,即使配置中的 `[serve].auth_mode`
是 `none` 也一样。每个运行实例都会在 `<Reasonix home>/server/instances/` 下写入自己的
单写者 heartbeat 文件;正常退出时只删除自己的文件,新实例则会惰性清理已确认进程死亡的记录。
因此多个 Web 实例可以共用同一个 Reasonix home,而不会相互覆盖登记状态。服务保持在前台运行,
按 Ctrl-C 停止。
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
请先开启认证再分享 URL:
```bash
reasonix serve --auth token
@@ -204,7 +218,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
reasonix serve --auth password --password 'temporary-password'
```
Token 模式会在终端打印带 `?token=...` 的分享链接;可通过 `--token` 或 `[serve].token`
Token 模式会在终端打印带 `#token=...` 的分享链接;Web 页面会先将 fragment 换成
HttpOnly Cookie,再启动 API 与 SSE 请求,从而避免 Token 进入请求 URL、浏览器历史、
Referrer 和访问日志。可通过 `--token` 或 `[serve].token`
复用固定 token。Password 模式必须在启动时传 `--password`,或在配置里保存 bcrypt hash:
```bash
+3 -3
View File
@@ -50,6 +50,7 @@ type chatTUI struct {
ctrl control.SessionAPI
label string
missing string // missing-key warning surfaced once in the banner, "" when ready
webHandoffState
// diagnostics is the process-owned TUI log/watchdog started before terminal
// takeover. Nil in unit tests that construct chatTUI without chatREPL.
diagnostics *tuiDiagnostics
@@ -4680,9 +4681,8 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
case "/currency":
m.echoLocalCommand(input)
return m.runCurrencySubcommand(input)
case "/help":
m.echoLocalCommand(input)
m.showHelp()
case "/help", "/web":
return m.runHelpOrWebSlash(input, typedCmd)
case "/memory":
m.echoLocalCommand(input)
m.showMemory(input)
+76
View File
@@ -0,0 +1,76 @@
package cli
import (
"os"
"reasonix/internal/agent"
tea "charm.land/bubbletea/v2"
)
// webHandoffState is populated by /web before the TUI restores the terminal.
// A fresh session carries only its reserved identity; an existing transcript
// also carries the materialized path so the Web controller can resume it.
type webHandoffState struct {
launchWebOnExit bool
launchWebResumePath string
launchWebSessionID string
launchWebModelRef string
}
func (m *chatTUI) runHelpOrWebSlash(input, command string) tea.Cmd {
m.echoLocalCommand(input)
if command == "/help" {
m.showHelp()
return nil
}
return m.runWebSlash()
}
func (m *chatTUI) runWebSlash() tea.Cmd {
if m.runtimeSwitchBusy() {
m.notice("wait for active work to finish, then retry /web")
return nil
}
if err := m.ctrl.Snapshot(); err != nil {
m.notice("web: could not save the current session: " + err.Error())
return nil
}
resumePath := m.ctrl.SessionPath()
if resumePath != "" {
info, err := os.Stat(resumePath)
switch {
case err == nil && !info.Mode().IsRegular():
m.notice("web: the current session path is not a regular file")
return nil
case err == nil:
case os.IsNotExist(err):
resumePath = ""
default:
m.notice("web: could not inspect the current session: " + err.Error())
return nil
}
}
m.followSessionLease()
m.launchWebOnExit = true
m.launchWebResumePath = resumePath
m.launchWebSessionID = agent.BranchID(m.ctrl.SessionPath())
m.launchWebModelRef = m.modelRef
return shutdownNow
}
func webHandoffArgs(sessionPath, sessionID, modelRef, profile string) []string {
args := []string{}
if sessionPath != "" {
args = append(args, "--resume", sessionPath)
} else if sessionID != "" {
args = append(args, "--session-id", sessionID)
}
if modelRef != "" {
args = append(args, "--model", modelRef)
}
if profile != "" {
args = append(args, "--profile", profile)
}
return args
}
+72
View File
@@ -0,0 +1,72 @@
package cli
import (
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/provider"
)
func TestWebSlashRequestsFrontendHandoff(t *testing.T) {
m := newTestChatTUI()
m.modelRef = "deepseek/deepseek-v4-flash"
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
ctrl.EnsureSessionPath()
t.Cleanup(ctrl.Close)
m.ctrl = ctrl
cmd := m.runSlashCommand("/web")
if !m.launchWebOnExit {
t.Fatal("/web did not mark the TUI for a Web frontend handoff")
}
if m.launchWebResumePath != "" {
t.Fatalf("fresh /web handoff resume path = %q, want a new Web session", m.launchWebResumePath)
}
wantID := agent.BranchID(ctrl.SessionPath())
if m.launchWebSessionID != wantID {
t.Fatalf("fresh /web handoff session id = %q, want %q", m.launchWebSessionID, wantID)
}
if m.launchWebModelRef != m.modelRef {
t.Fatalf("fresh /web handoff model = %q, want %q", m.launchWebModelRef, m.modelRef)
}
args := strings.Join(webHandoffArgs(m.launchWebResumePath, m.launchWebSessionID, m.launchWebModelRef, "balanced"), " ")
if strings.Contains(args, "--resume") || !strings.Contains(args, "--session-id "+wantID) {
t.Fatalf("fresh /web handoff args = %q, must not resume the reserved path", args)
}
if cmd == nil {
t.Fatal("/web did not request TUI shutdown")
}
msg := cmd()
if _, ok := msg.(tuiShutdownMsg); !ok {
t.Fatalf("/web command returned %T, want tuiShutdownMsg", msg)
}
}
func TestWebSlashResumesMaterializedSession(t *testing.T) {
m := newTestChatTUI()
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
ctrl.EnsureSessionPath()
t.Cleanup(ctrl.Close)
path := ctrl.SessionPath()
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
m.ctrl = ctrl
if cmd := m.runSlashCommand("/web"); cmd == nil {
t.Fatal("/web did not request TUI shutdown")
}
if m.launchWebResumePath != path {
t.Fatalf("materialized /web handoff resume path = %q, want %q", m.launchWebResumePath, path)
}
if m.launchWebSessionID != agent.BranchID(path) {
t.Fatalf("materialized /web handoff session id = %q, want %q", m.launchWebSessionID, agent.BranchID(path))
}
args := strings.Join(webHandoffArgs(m.launchWebResumePath, m.launchWebSessionID, m.launchWebModelRef, "balanced"), " ")
if !strings.Contains(args, "--resume "+path) || strings.Contains(args, "--session-id") {
t.Fatalf("materialized /web handoff args = %q, want resume path %q", args, path)
}
}
+74 -90
View File
@@ -14,7 +14,6 @@ import (
"io"
"log/slog"
"math"
"net"
"net/url"
"os"
"os/signal"
@@ -52,6 +51,8 @@ import (
var (
runInteractiveSession = chatREPL
cliIsInteractive = isInteractive
runWebCommand = runWeb
openBrowserURL = openInBrowser
)
// Run is the CLI entry point; it returns a process exit code.
@@ -126,6 +127,8 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
return runInteractiveSession(rest, version)
case "serve":
return runServe(rest)
case "web":
return runWebCommand(rest)
case "setup":
configureCLIThemeFromConfigForTTYOutput()
return setupConfig(rest)
@@ -216,7 +219,7 @@ func isDefaultInteractiveFlag(arg string) bool {
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
switch cmd {
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "bot", "upgrade", "update":
case "", "run", "chat", "code", "serve", "web", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "bot", "upgrade", "update":
return true
default:
return false
@@ -794,18 +797,26 @@ func runAgent(args []string, version string) int {
return completion.exitCode
}
// runServe exposes the controller over HTTP+SSE: events stream to the browser,
// commands arrive as JSON POSTs. The Broadcaster is the controller's event sink,
// so the same typed stream the chat TUI consumes reaches web clients — the
// transport-agnostic controller driven by a second frontend.
func runServe(args []string) int {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
func runServeWithOptions(args []string, opts serveRunOptions) int {
if opts.command == "" {
opts.command = "serve"
}
fs := flag.NewFlagSet(opts.command, flag.ContinueOnError)
model := fs.String("model", "", "provider name (default: config default_model)")
profileFlag := fs.String("profile", "balanced", "runtime profile: economy | balanced | delivery")
maxSteps := fs.Int("max-steps", 0, "one-off max tool-call rounds (0 = automatic)")
addr := fs.String("addr", "127.0.0.1:8787", "listen address")
resume := fs.String("resume", "", "resume a saved session file")
auth := fs.String("auth", "", "auth mode: none, token, or password (default: none)")
sessionIDValue := ""
sessionID := &sessionIDValue
if opts.command == "web" {
sessionID = fs.String("session-id", "", "bind a fresh Web session identity (used by /web handoff)")
}
authHelp := "auth mode: none, token, or password (default: config/none)"
if opts.command == "web" {
authHelp = "auth mode: none, token, or password (default: generated token)"
}
auth := fs.String("auth", "", authHelp)
token := fs.String("token", "", "pre-shared token for auth=token (auto-generated if empty)")
password := fs.String("password", "", "password for auth=password (use --hash-password to store a hash instead)")
hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password and exit")
@@ -813,9 +824,27 @@ func runServe(args []string) int {
portFile := fs.String("port-file", "", "write the actual bound listen address (host:port) to this file after binding")
tokenFile := fs.String("token-file", "", "read the auth=token pre-shared token from this file (overrides --token; keeps the secret out of argv)")
pidFile := fs.String("pid-file", "", "write the server process id to this file")
openBrowser := fs.Bool("open", opts.openBrowser, "open the Web UI in the default browser")
noOpen := fs.Bool("no-open", false, "do not open the Web UI in the default browser")
if code, ok := parseCommandFlags(fs, args); !ok {
return code
}
authExplicit := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "auth" {
authExplicit = true
}
})
if *resume != "" && *sessionID != "" {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--resume and --session-id cannot be used together")
return 2
}
if *sessionID != "" {
if err := validateWebSessionID(*sessionID); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
}
profile, err := parseRuntimeProfile(*profileFlag)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
@@ -842,7 +871,10 @@ func runServe(args []string) int {
cfg, _ := config.Load()
// Build serve config, merging CLI flags over config file.
serveCfg := cfg.Serve
serveCfg := serveConfigWithCommandDefaults(opts.command, authExplicit, cfg.Serve)
// `reasonix web` is a local browser entry point and defaults to a freshly
// generated token. `reasonix serve` keeps its existing config-driven default,
// and an explicit --auth always wins for both commands.
if *auth != "" {
serveCfg.AuthMode = *auth
}
@@ -924,6 +956,13 @@ func runServe(args []string) int {
// Auto-save target: reuse the resumed file, else a fresh one — same as chat.
if *resume != "" {
ctrl.Resume(resumeSession, *resume)
} else if *sessionID != "" {
freshPath, err := freshWebSessionPath(ctrl.SessionDir(), *sessionID)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
ctrl.SetFreshSessionPath(freshPath)
}
ctrl.EnsureSessionPath()
// Fresh sessions take the lease too (defensive: the path is brand new); a
@@ -935,86 +974,12 @@ func runServe(args []string) int {
srv := serve.New(ctrl, bc, serveCfg)
srv.SetSessionLeases(leases)
// With --port-file the supervisor needs the real bound port (--addr may be
// 127.0.0.1:0), so listen first, record the address, then serve on the
// existing listener.
var ln net.Listener
displayAddr := *addr
if *portFile != "" {
var lerr error
ln, lerr = net.Listen("tcp", *addr)
if lerr != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, lerr)
return 1
}
displayAddr = ln.Addr().String()
if err := writeServeAddrFile(*portFile, displayAddr); err != nil {
_ = ln.Close()
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer os.Remove(*portFile)
}
if *pidFile != "" {
if err := writeServePidFile(*pidFile); err != nil {
if ln != nil {
_ = ln.Close()
}
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer os.Remove(*pidFile)
}
srv.EnableProviderSetupForListener(displayAddr)
fmt.Printf("reasonix serve — %s on http://%s\n", ctrl.Label(), displayAddr)
if srv.AuthMode() == "token" {
fmt.Printf(" auth: token\n")
// Under --port-file the process is supervised (e.g. remote bootstrap):
// stdout is redirected to a log, so printing the token here would leak it
// into a file readable by other same-machine users. The supervisor
// already holds the token (it wrote --token-file), so suppress the share
// URL and print only the token-file reference.
if *portFile != "" && *tokenFile != "" {
fmt.Printf(" share: http://%s/ (token in %s)\n", displayAddr, *tokenFile)
} else {
fmt.Printf(" share: http://%s/?token=%s\n", displayAddr, srv.AuthToken())
}
} else if srv.AuthMode() == "password" {
fmt.Printf(" auth: password (login at http://%s/login)\n", displayAddr)
}
if warning := serve.PlainHTTPAuthWarning(serveCfg, displayAddr); warning != "" {
fmt.Fprintf(os.Stderr, " %s\n", warning)
}
// Balance is diagnostics, not readiness. Run it off the serving path so a
// slow or unauthenticated Provider endpoint cannot leave a published port
// file pointing at a listener whose HTTP accept loop has not started yet.
go func() {
if b, err := ctrl.Balance(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, " balance: error — %v\n", err)
} else if b == nil {
fmt.Fprintf(os.Stderr, " balance: not configured (no balance_url for this provider)\n")
} else {
fmt.Printf(" balance: %s\n", b.Display())
}
}()
// Use graceful shutdown so SIGINT/SIGTERM drain active connections.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if ln != nil {
if err := srv.RunGracefulListener(ctx, ln); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
}
if err := srv.RunGraceful(ctx, *addr); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
return runServeFrontend(ctrl, srv, serveCfg, serveFrontendOptions{
command: opts.command, address: *addr,
portFile: *portFile, tokenFile: *tokenFile, pidFile: *pidFile,
openBrowser: *openBrowser && !*noOpen,
hasSession: *resume != "" || *sessionID != "",
})
}
// chatREPL is an interactive session: a single persistent agent/session and a
@@ -1341,7 +1306,14 @@ func chatREPL(args []string, version string) int {
// because Controller.Close() runs SessionEnd hooks and kills plugin
// subprocesses — operations that corrupt bubbletea's terminal raw mode
// when executed while the TUI is alive.
launchWeb := false
launchWebPath := ""
launchWebSessionID := ""
launchWebModelRef := ""
launchWebProfile := ""
if fm, ok := final.(chatTUI); ok {
launchWeb = fm.launchWebOnExit
launchWebProfile = fm.runtimeProfile
for _, oc := range fm.oldControllers {
if c, ok := oc.(*control.Controller); ok {
reporter.RecordRecovery(c.DrainRecoveryMetrics())
@@ -1349,6 +1321,9 @@ func chatREPL(args []string, version string) int {
oc.Close()
}
if fm.ctrl != nil {
launchWebPath = fm.launchWebResumePath
launchWebSessionID = fm.launchWebSessionID
launchWebModelRef = fm.launchWebModelRef
if c, ok := fm.ctrl.(*control.Controller); ok {
reporter.RecordRecovery(c.DrainRecoveryMetrics())
}
@@ -1365,6 +1340,15 @@ func chatREPL(args []string, version string) int {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, runErr)
return 1
}
if launchWeb {
// The Web runtime resumes a materialized TUI transcript or binds the exact
// reserved identity for a never-used session. Release the TUI lease before
// rebuilding the controller or the handoff would correctly reject its own
// session as already in use. The deferred Release remains as a harmless
// final guard for every other return path.
leases.Release()
return runWebCommand(webHandoffArgs(launchWebPath, launchWebSessionID, launchWebModelRef, launchWebProfile))
}
return 0
}
+11 -4
View File
@@ -268,7 +268,7 @@ func remoteConnectCLI(args []string, version string) int {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
localURL, ferr := forwardServe(client, res.State.Addr, syntax.localPort, res.Token)
localURL, ferr := forwardServe(ctx, client, res.State.Addr, syntax.localPort, res.Token)
if ferr != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, ferr)
return 1
@@ -303,7 +303,7 @@ func applyConfiguredForwards(client *remote.Client, entry config.RemoteHostEntry
// forwardServe adds the reserved "serve" local forward to the remote serve
// address and returns the local URL (with token).
func forwardServe(client *remote.Client, remoteAddr string, localPort int, token string) (string, error) {
func forwardServe(ctx context.Context, client *remote.Client, remoteAddr string, localPort int, token string) (string, error) {
bind := "127.0.0.1:0"
if localPort > 0 {
bind = fmt.Sprintf("127.0.0.1:%d", localPort)
@@ -317,7 +317,7 @@ func forwardServe(client *remote.Client, remoteAddr string, localPort int, token
if err != nil {
return "", err
}
return fmt.Sprintf("http://%s/?token=%s", bound, token), nil
return remoteServeBrowserURL(ctx, bound, token), nil
}
func normalizeBind(bind string) string {
@@ -734,5 +734,12 @@ func openInBrowser(url string) error {
cmd, args = "xdg-open", []string{url}
}
c := exec.Command(cmd, args...)
return c.Start()
if err := c.Start(); err != nil {
return err
}
// Browser launchers normally exit immediately after handing the URL to the
// desktop session. Reap that helper asynchronously so a long-lived web or
// remote process does not retain its process resources.
go func() { _ = c.Wait() }()
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package cli
import (
"context"
"net/http"
"net/url"
"time"
)
const remoteServeCapabilityTimeout = 2 * time.Second
// remoteServeBrowserURL keeps fragments for current Serve versions while
// preserving reconnects to already-running legacy processes that only accept
// the query-token bootstrap. Probe failures fall back to the compatible path.
func remoteServeBrowserURL(ctx context.Context, bound, token string) string {
base := "http://" + bound + "/"
escapedToken := url.QueryEscape(token)
if remoteServeSupportsFragmentToken(ctx, base) {
return base + "#token=" + escapedToken
}
return base + "?token=" + escapedToken
}
// remoteServeSupportsFragmentToken probes without sending the secret. Current
// Serve versions expose /auth/token and reject GET with 405; legacy token auth
// rejects the unauthenticated request with 401 before routing the endpoint.
func remoteServeSupportsFragmentToken(ctx context.Context, base string) bool {
probeCtx, cancel := context.WithTimeout(ctx, remoteServeCapabilityTimeout)
defer cancel()
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, base+"auth/token", nil)
if err != nil {
return false
}
req.Close = true
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusMethodNotAllowed
}
+64
View File
@@ -0,0 +1,64 @@
package cli
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/serve"
)
func TestRemoteServeBrowserURLUsesFragmentForCurrentServe(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), config.ServeConfig{AuthMode: "token", Token: "current secret/+"})
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
bound := strings.TrimPrefix(ts.URL, "http://")
got := remoteServeBrowserURL(context.Background(), bound, "current secret/+")
want := ts.URL + "/#token=current+secret%2F%2B"
if got != want {
t.Fatalf("remoteServeBrowserURL() = %q, want %q", got, want)
}
}
func TestRemoteServeBrowserURLFallsBackForReusedV1214ServeContract(t *testing.T) {
const token = "legacy secret/+"
// v1.21.4 token auth denies unauthenticated /auth/token requests and
// bootstraps browser cookies only from the legacy query parameter.
legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") != token {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{Name: "reasonix_token", Value: token, Path: "/", HttpOnly: true})
http.Redirect(w, r, "/", http.StatusFound)
}))
t.Cleanup(legacy.Close)
bound := strings.TrimPrefix(legacy.URL, "http://")
got := remoteServeBrowserURL(context.Background(), bound, token)
want := legacy.URL + "/?token=legacy+secret%2F%2B"
if got != want {
t.Fatalf("remoteServeBrowserURL() = %q, want legacy query bootstrap %q", got, want)
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(got)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("legacy bootstrap status = %d, want %d", resp.StatusCode, http.StatusFound)
}
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != "reasonix_token" || !cookies[0].HttpOnly {
t.Fatalf("legacy bootstrap cookies = %#v, want HttpOnly reasonix_token", cookies)
}
}
+182
View File
@@ -0,0 +1,182 @@
package cli
import (
"context"
"fmt"
"net"
"net/url"
"os"
"os/signal"
"syscall"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/i18n"
"reasonix/internal/serve"
)
// runServe exposes the controller's HTTP and SSE frontend.
func runServe(args []string) int {
return runServeWithOptions(args, serveRunOptions{command: "serve"})
}
// runWeb adds local browser lifecycle behavior to the Serve frontend.
func runWeb(args []string) int {
return runServeWithOptions(args, serveRunOptions{command: "web", openBrowser: true})
}
type serveRunOptions struct {
command string
openBrowser bool
}
type serveFrontendOptions struct {
command string
address string
portFile string
tokenFile string
pidFile string
openBrowser bool
hasSession bool
}
type serveFrontendResources struct {
listener net.Listener
displayAddr string
artifacts []string
registration *webInstanceRegistration
}
func prepareServeFrontend(opts serveFrontendOptions) (_ *serveFrontendResources, err error) {
resources := &serveFrontendResources{displayAddr: opts.address}
defer func() {
if err != nil {
resources.release(true)
}
}()
if opts.portFile != "" || opts.command == "web" || opts.openBrowser {
if opts.command == "web" {
resources.listener, err = listenWebWithPortRetry(opts.address)
} else {
resources.listener, err = net.Listen("tcp", opts.address)
}
if err != nil {
return nil, err
}
resources.displayAddr = resources.listener.Addr().String()
requested, selected := requestedPort(opts.address), requestedPort(resources.displayAddr)
if opts.command == "web" && requested != 0 && requested != selected {
fmt.Fprintf(os.Stderr, " port %d is in use; using %d instead\n", requested, selected)
}
if opts.portFile != "" {
if err = writeServeAddrFile(opts.portFile, resources.displayAddr); err != nil {
return nil, err
}
resources.artifacts = append(resources.artifacts, opts.portFile)
}
}
if opts.pidFile != "" {
if err = writeServePidFile(opts.pidFile); err != nil {
return nil, err
}
resources.artifacts = append(resources.artifacts, opts.pidFile)
}
if opts.command == "web" {
resources.registration, err = registerWebInstance(config.ReasonixHomeDir(), resources.displayAddr)
if err != nil {
return nil, err
}
}
return resources, nil
}
func (r *serveFrontendResources) release(closeListener bool) {
if closeListener && r.listener != nil {
_ = r.listener.Close()
}
if r.registration != nil {
r.registration.Release()
}
for _, path := range r.artifacts {
_ = os.Remove(path)
}
}
func runServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, opts serveFrontendOptions) int {
resources, err := prepareServeFrontend(opts)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer resources.release(false)
srv.EnableProviderSetupForListener(resources.displayAddr)
reportServeFrontend(ctrl, srv, cfg, resources.displayAddr, opts)
startServeBalanceDiagnostics(ctrl)
return serveFrontendLoop(ctrl, srv, resources, opts)
}
func reportServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, address string, opts serveFrontendOptions) {
fmt.Printf("reasonix %s — %s on http://%s\n", opts.command, ctrl.Label(), address)
if srv.AuthMode() == "token" {
fmt.Println(" auth: token")
// Supervised Serve already owns the token file, so avoid logging its value.
if opts.portFile != "" && opts.tokenFile != "" {
fmt.Printf(" share: http://%s/ (token in %s)\n", address, opts.tokenFile)
} else {
fmt.Printf(" share: http://%s/#token=%s\n", address, url.QueryEscape(srv.AuthToken()))
}
} else if srv.AuthMode() == "password" {
fmt.Printf(" auth: password (login at http://%s/login)\n", address)
}
if warning := serve.PlainHTTPAuthWarning(cfg, address); warning != "" {
fmt.Fprintf(os.Stderr, " %s\n", warning)
}
}
func startServeBalanceDiagnostics(ctrl *control.Controller) {
go func() {
if balance, err := ctrl.Balance(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, " balance: error — %v\n", err)
} else if balance == nil {
fmt.Fprintln(os.Stderr, " balance: not configured (no balance_url for this provider)")
} else {
fmt.Printf(" balance: %s\n", balance.Display())
}
}()
}
func serveFrontendLoop(ctrl *control.Controller, srv *serve.Server, resources *serveFrontendResources, opts serveFrontendOptions) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if resources.listener == nil {
if err := srv.RunGraceful(ctx, opts.address); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
}
var serveErr error
if opts.openBrowser {
sessionID := ""
if opts.hasSession {
sessionID = agent.BranchID(ctrl.SessionPath())
}
serveErr = runServeListenerAfterReady(ctx, srv, resources.listener, resources.displayAddr, func() {
browserURL, err := launchWebBrowser(srv, resources.displayAddr, sessionID)
if err != nil {
fmt.Fprintf(os.Stderr, " browser: could not open %s — %v\n", browserURL, err)
} else {
fmt.Printf(" browser: %s\n", browserURL)
}
})
} else {
serveErr = srv.RunGracefulListener(ctx, resources.listener)
}
if serveErr != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, serveErr)
return 1
}
return 0
}
+16 -11
View File
@@ -107,20 +107,13 @@ func cliCompletionRootSpec() cliCompletionSpec {
completionFlag("--version -v", cliCompletionNoValue),
}, help)}
serveFlags := cliServeCompletionFlags(model, profile, help)
root.subcommands = []cliCompletionSpec{
completionSpec("run", runFlags),
completionSpecWithAliases("chat", []string{"code"}, interactiveFlags),
completionSpec("serve", []cliCompletionFlag{
model, profile,
completionFlag("--max-steps", cliCompletionStaticValue),
completionFlag("--addr", cliCompletionStaticValue),
// serve loads the path with open/loadResumableSession — file path only,
// not branch IDs (SessionValue would complete IDs that fail at runtime).
completionFlag("--resume", cliCompletionPathValue),
completionFlag("--auth", cliCompletionStaticValue, "none", "token", "password"),
completionFlag("--token --password --port-file --token-file --pid-file", cliCompletionStaticValue),
completionFlag("--hash-password --behind-proxy", cliCompletionNoValue), help,
}),
completionSpec("serve", serveFlags),
completionSpec("web", serveFlags),
completionSpec("setup", []cliCompletionFlag{completionFlag("--local -l", cliCompletionNoValue), help}),
completionSpec("config", []cliCompletionFlag{help},
completionSpec("auto-plan", []cliCompletionFlag{completionFlag("--local", cliCompletionNoValue), help}),
@@ -292,6 +285,18 @@ func cliCompletionRootSpec() cliCompletionSpec {
return root
}
func cliServeCompletionFlags(model, profile, help cliCompletionFlag) []cliCompletionFlag {
return []cliCompletionFlag{
model, profile,
completionFlag("--max-steps --addr", cliCompletionStaticValue),
// Serve/Web resume accepts file paths, not branch IDs.
completionFlag("--resume", cliCompletionPathValue),
completionFlag("--auth", cliCompletionStaticValue, "none", "token", "password"),
completionFlag("--token --password --port-file --token-file --pid-file", cliCompletionStaticValue),
completionFlag("--hash-password --behind-proxy --open --no-open", cliCompletionNoValue), help,
}
}
func subagentCompletionFlags(model, effort, help cliCompletionFlag) []cliCompletionFlag {
return []cliCompletionFlag{
completionFlag("--description --prompt --prompt-file --tools --color --dir", cliCompletionStaticValue),
+1
View File
@@ -55,6 +55,7 @@ func builtinSlashSpecs() []builtinSlashSpec {
{name: "/language", insert: "/language ", hint: i18n.M.CmdLanguage, descend: true, showInHelp: true},
{name: "/currency", insert: "/currency ", hint: i18n.M.CmdCurrency, descend: true, showInHelp: true},
{name: "/help", insert: "/help", hint: i18n.M.CmdHelp, showInHelp: true},
{name: "/web", insert: "/web", hint: i18n.M.CmdWeb, showInHelp: true},
{name: "/docs", aliases: []string{"/reasonix:docs"}, insert: "/docs ", hint: i18n.M.CmdDocs, showInHelp: true},
{name: "/memory", insert: "/memory ", hint: i18n.M.CmdMemory, showInHelp: true},
{name: "/migrate", aliases: []string{"/migration"}, insert: "/migrate", hint: i18n.M.CmdMigrate, showInHelp: true},
+89
View File
@@ -0,0 +1,89 @@
package cli
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"reasonix/internal/serve"
)
// runServeListenerAfterReady opens the browser only after HTTP responds.
func runServeListenerAfterReady(ctx context.Context, srv *serve.Server, ln net.Listener, addr string, onReady func()) error {
serveCtx, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- srv.RunGracefulListener(serveCtx, ln) }()
if err := waitForServeHTTPReady(serveCtx, addr); err != nil {
cancel()
if serveErr := <-done; serveErr != nil {
return fmt.Errorf("wait for Web server readiness: %w (server: %w)", err, serveErr)
}
return fmt.Errorf("wait for Web server readiness: %w", err)
}
if onReady != nil {
onReady()
}
return <-done
}
func waitForServeHTTPReady(ctx context.Context, addr string) error {
readyCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(readyCtx, http.MethodGet, "http://"+browserHostPort(addr)+"/", nil)
if err != nil {
return err
}
req.Close = true
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
// webBrowserURL maps wildcard binds to loopback and keeps tokens in fragments.
func webBrowserURL(srv *serve.Server, addr, sessionID string) string {
base := "http://" + browserHostPort(addr)
entryPath := "/"
if sessionID != "" {
entryPath = "/sessions/" + url.PathEscape(sessionID)
}
switch srv.AuthMode() {
case "token":
return base + entryPath + "#token=" + url.QueryEscape(srv.AuthToken())
case "password":
if sessionID != "" {
return base + entryPath
}
return base + "/login"
default:
return base + entryPath
}
}
func launchWebBrowser(srv *serve.Server, addr, sessionID string) (string, error) {
browserURL := webBrowserURL(srv, addr, sessionID)
return browserURL, openBrowserURL(browserURL)
}
func browserHostPort(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
switch strings.TrimSpace(host) {
case "", "0.0.0.0", "::":
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
+20
View File
@@ -0,0 +1,20 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package cli
import (
"errors"
"syscall"
)
func webAddressInUse(err error) bool {
return errors.Is(err, syscall.EADDRINUSE)
}
func webInstanceProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || err == syscall.EPERM
}
+28
View File
@@ -0,0 +1,28 @@
//go:build windows
package cli
import (
"errors"
"golang.org/x/sys/windows"
)
const webInstanceStillActiveExitCode = 259
func webAddressInUse(err error) bool {
return errors.Is(err, windows.WSAEADDRINUSE)
}
func webInstanceProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return errors.Is(err, windows.ERROR_ACCESS_DENIED)
}
defer windows.CloseHandle(handle)
var code uint32
return windows.GetExitCodeProcess(handle, &code) == nil && code == webInstanceStillActiveExitCode
}
+307
View File
@@ -0,0 +1,307 @@
package cli
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"reasonix/internal/config"
"reasonix/internal/fileutil"
"reasonix/internal/store"
)
func serveConfigWithCommandDefaults(command string, authExplicit bool, cfg config.ServeConfig) config.ServeConfig {
if command == "web" && !authExplicit {
cfg.AuthMode = "token"
}
return cfg
}
const (
webPortRetryLimit = 100
webInstanceHeartbeat = 15 * time.Second
maxWebSessionIDBytes = 218 // leaves room for .jsonl and session sidecars
webInstanceDirectoryName = "instances"
)
// listenWebWithPortRetry binds addr, walking port+1 only when a concrete port
// is already occupied. Port 0 remains an ordinary kernel-assigned ephemeral
// bind, and non-EADDRINUSE failures are returned immediately.
func listenWebWithPortRetry(addr string) (net.Listener, error) {
host, rawPort, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
port, err := strconv.Atoi(rawPort)
if err != nil || port < 0 || port > 65535 {
return nil, fmt.Errorf("invalid listen port %q", rawPort)
}
if port == 0 {
return net.Listen("tcp", addr)
}
for attempt := 0; ; attempt++ {
candidate := net.JoinHostPort(host, strconv.Itoa(port))
ln, listenErr := net.Listen("tcp", candidate)
if listenErr == nil {
return ln, nil
}
if !webAddressInUse(listenErr) || attempt >= webPortRetryLimit || port >= 65535 {
return nil, listenErr
}
port++
}
}
func requestedPort(addr string) int {
_, rawPort, err := net.SplitHostPort(addr)
if err != nil {
return -1
}
port, err := strconv.Atoi(rawPort)
if err != nil {
return -1
}
return port
}
func validateWebSessionID(id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("--session-id cannot be empty")
}
if !utf8.ValidString(id) || len(id) > maxWebSessionIDBytes {
return fmt.Errorf("invalid Web session identity %q", id)
}
if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || strings.IndexByte(id, 0) >= 0 {
return fmt.Errorf("invalid Web session identity %q", id)
}
if !store.IsSessionTranscriptName(id + ".jsonl") {
return fmt.Errorf("invalid Web session identity %q", id)
}
return nil
}
func freshWebSessionPath(dir, id string) (string, error) {
if err := validateWebSessionID(id); err != nil {
return "", err
}
path := filepath.Join(dir, id+".jsonl")
if _, err := os.Lstat(path); err == nil {
return "", fmt.Errorf("fresh Web session already exists: %s", path)
} else if !os.IsNotExist(err) {
return "", err
}
return path, nil
}
// webInstanceRecord is stable for independent operator-facing readers.
// Unknown fields remain forward-compatible with independent readers.
type webInstanceRecord struct {
ServerID string `json:"server_id"`
PID int `json:"pid"`
Host string `json:"host"`
Port int `json:"port"`
StartedAt int64 `json:"started_at"`
HeartbeatAt int64 `json:"heartbeat_at"`
}
type webInstanceRegistry struct {
dir string
now func() time.Time
heartbeatInterval time.Duration
processAlive func(int) bool
}
type webInstanceRegistration struct {
path string
record webInstanceRecord
registry *webInstanceRegistry
stop chan struct{}
done chan struct{}
releaseOne sync.Once
}
func registerWebInstance(reasonixHome, addr string) (*webInstanceRegistration, error) {
if strings.TrimSpace(reasonixHome) == "" {
return nil, errors.New("cannot register Web instance: Reasonix home is empty")
}
registry := &webInstanceRegistry{
dir: filepath.Join(reasonixHome, "server", webInstanceDirectoryName),
now: time.Now,
heartbeatInterval: webInstanceHeartbeat,
processAlive: webInstanceProcessAlive,
}
return registry.register(addr, os.Getpid())
}
func (r *webInstanceRegistry) register(addr string, pid int) (*webInstanceRegistration, error) {
if strings.TrimSpace(r.dir) == "" {
return nil, errors.New("cannot register Web instance: Reasonix home is empty")
}
host, rawPort, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("register Web instance: %w", err)
}
port, err := strconv.Atoi(rawPort)
if err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("register Web instance: invalid bound port %q", rawPort)
}
if err := os.MkdirAll(r.dir, 0o700); err != nil {
return nil, fmt.Errorf("create Web instance registry: %w", err)
}
if err := r.sweepStale(); err != nil {
return nil, fmt.Errorf("sweep Web instance registry: %w", err)
}
now := r.now().UnixMilli()
for range 8 {
serverID, err := randomWebInstanceID()
if err != nil {
return nil, fmt.Errorf("generate Web instance id: %w", err)
}
record := webInstanceRecord{
ServerID: serverID,
PID: pid,
Host: host,
Port: port,
StartedAt: now,
HeartbeatAt: now,
}
path := filepath.Join(r.dir, serverID+".json")
data, err := json.Marshal(record)
if err != nil {
return nil, err
}
if err := fileutil.AtomicCreateFile(path, data, 0o600); err != nil {
if errors.Is(err, os.ErrExist) {
continue
}
return nil, fmt.Errorf("register Web instance: %w", err)
}
reg := &webInstanceRegistration{
path: path,
record: record,
registry: r,
stop: make(chan struct{}),
done: make(chan struct{}),
}
go reg.heartbeat()
return reg, nil
}
return nil, errors.New("register Web instance: could not allocate a unique id")
}
func randomWebInstanceID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
func (r *webInstanceRegistry) sweepStale() error {
entries, err := os.ReadDir(r.dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(r.dir, entry.Name())
record, ok := readWebInstanceRecord(path)
// Malformed files may belong to a newer/live writer. Only delete an entry
// that can be positively identified as owned by a dead process.
if !ok || r.processAlive(record.PID) {
continue
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func (r *webInstanceRegistry) listLive() ([]webInstanceRecord, error) {
if err := r.sweepStale(); err != nil {
return nil, err
}
entries, err := os.ReadDir(r.dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
live := make([]webInstanceRecord, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
if record, ok := readWebInstanceRecord(filepath.Join(r.dir, entry.Name())); ok && r.processAlive(record.PID) {
live = append(live, record)
}
}
sort.Slice(live, func(i, j int) bool { return live[i].StartedAt < live[j].StartedAt })
return live, nil
}
func readWebInstanceRecord(path string) (webInstanceRecord, bool) {
data, err := os.ReadFile(path)
if err != nil {
return webInstanceRecord{}, false
}
var record webInstanceRecord
if json.Unmarshal(data, &record) != nil || record.ServerID == "" || record.PID <= 0 || record.Host == "" || record.Port <= 0 || record.StartedAt <= 0 || record.HeartbeatAt <= 0 {
return webInstanceRecord{}, false
}
return record, true
}
func (r *webInstanceRegistration) heartbeat() {
defer close(r.done)
interval := r.registry.heartbeatInterval
if interval <= 0 {
<-r.stop
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.record.HeartbeatAt = r.registry.now().UnixMilli()
if data, err := json.Marshal(r.record); err == nil {
_ = fileutil.AtomicWriteFile(r.path, data, 0o600)
}
case <-r.stop:
return
}
}
}
// Release stops heartbeats before removing the single-writer instance file, so
// an in-flight atomic rename cannot recreate a supposedly released entry.
func (r *webInstanceRegistration) Release() {
if r == nil {
return
}
r.releaseOne.Do(func() {
close(r.stop)
<-r.done
_ = os.Remove(r.path)
})
}
+190
View File
@@ -0,0 +1,190 @@
package cli
import (
"encoding/json"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
"time"
"reasonix/internal/config"
)
func TestServeConfigWithCommandDefaults(t *testing.T) {
tests := []struct {
name string
command string
authExplicit bool
configured string
want string
}{
{name: "web generates token by default", command: "web", want: "token"},
{name: "web overrides configured none by default", command: "web", configured: "none", want: "token"},
{name: "web explicit auth wins", command: "web", authExplicit: true, configured: "none", want: "none"},
{name: "serve stays config driven", command: "serve", configured: "password", want: "password"},
{name: "serve empty stays backward compatible", command: "serve", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := serveConfigWithCommandDefaults(tt.command, tt.authExplicit, config.ServeConfig{AuthMode: tt.configured})
if got.AuthMode != tt.want {
t.Fatalf("AuthMode = %q, want %q", got.AuthMode, tt.want)
}
})
}
}
func TestListenWebWithPortRetryUsesNextAvailablePort(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
base := occupied.Addr().(*net.TCPAddr).Port
if base == 65535 {
t.Skip("ephemeral allocation left no higher port")
}
ln, err := listenWebWithPortRetry(net.JoinHostPort("127.0.0.1", strconv.Itoa(base)))
if err != nil {
t.Fatal(err)
}
defer ln.Close()
got := ln.Addr().(*net.TCPAddr).Port
if got <= base || got > base+webPortRetryLimit+1 {
t.Fatalf("bound port = %d, want a higher port near occupied %d", got, base)
}
}
func TestListenWebWithPortRetryPreservesEphemeralPort(t *testing.T) {
ln, err := listenWebWithPortRetry("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
if got := ln.Addr().(*net.TCPAddr).Port; got == 0 {
t.Fatal("kernel did not assign an ephemeral port")
}
}
func TestValidateWebSessionID(t *testing.T) {
for _, valid := range []string{"20260809-122436.032610000-deepseek-v4-flash", "session with space", "a.b-c_d"} {
if err := validateWebSessionID(valid); err != nil {
t.Errorf("validateWebSessionID(%q) = %v", valid, err)
}
}
for _, invalid := range []string{"", " ", ".", "..", "a/b", `a\b`, "thing.events"} {
if err := validateWebSessionID(invalid); err == nil {
t.Errorf("validateWebSessionID(%q) succeeded", invalid)
}
}
}
func TestFreshWebSessionPathKeepsReservedIdentityWithoutMaterializing(t *testing.T) {
dir := t.TempDir()
got, err := freshWebSessionPath(dir, "reserved-session")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dir, "reserved-session.jsonl")
if got != want {
t.Fatalf("fresh path = %q, want %q", got, want)
}
if _, err := os.Stat(got); !os.IsNotExist(err) {
t.Fatalf("fresh identity should stay lazy on disk, stat error = %v", err)
}
if err := os.WriteFile(got, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := freshWebSessionPath(dir, "reserved-session"); err == nil {
t.Fatal("existing transcript was accepted as a fresh Web identity")
}
}
func TestWebInstanceRegistryPreservesIndependentInstances(t *testing.T) {
dir := filepath.Join(t.TempDir(), "server", "instances")
now := time.UnixMilli(1000)
alive := map[int]bool{101: true, 202: true}
registry := &webInstanceRegistry{
dir: dir,
now: func() time.Time { now = now.Add(time.Millisecond); return now },
heartbeatInterval: 0,
processAlive: func(pid int) bool { return alive[pid] },
}
first, err := registry.register("127.0.0.1:8787", 101)
if err != nil {
t.Fatal(err)
}
t.Cleanup(first.Release)
second, err := registry.register("127.0.0.1:8788", 202)
if err != nil {
t.Fatal(err)
}
t.Cleanup(second.Release)
dirInfo, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && dirInfo.Mode().Perm() != 0o700 {
t.Fatalf("registry directory mode = %o, want 700", dirInfo.Mode().Perm())
}
live, err := registry.listLive()
if err != nil {
t.Fatal(err)
}
if len(live) != 2 || live[0].Port != 8787 || live[1].Port != 8788 {
t.Fatalf("live instances = %+v, want ports 8787 and 8788", live)
}
for _, reg := range []*webInstanceRegistration{first, second} {
info, err := os.Stat(reg.path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("instance mode = %o, want 600", info.Mode().Perm())
}
}
first.Release()
live, err = registry.listLive()
if err != nil {
t.Fatal(err)
}
if len(live) != 1 || live[0].PID != 202 {
t.Fatalf("live after releasing first = %+v, want only second", live)
}
}
func TestWebInstanceRegistrySweepsOnlyConfirmedDeadRecords(t *testing.T) {
dir := t.TempDir()
deadPath := filepath.Join(dir, "dead.json")
dead := webInstanceRecord{ServerID: "dead", PID: 303, Host: "127.0.0.1", Port: 8787, StartedAt: 1, HeartbeatAt: 1}
data, err := json.Marshal(dead)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(deadPath, data, 0o600); err != nil {
t.Fatal(err)
}
garbagePath := filepath.Join(dir, "future.json")
if err := os.WriteFile(garbagePath, []byte(`{"future_schema":true}`), 0o600); err != nil {
t.Fatal(err)
}
registry := &webInstanceRegistry{
dir: dir,
processAlive: func(int) bool { return false },
}
if err := registry.sweepStale(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(deadPath); !os.IsNotExist(err) {
t.Fatalf("dead record still exists: %v", err)
}
if _, err := os.Stat(garbagePath); err != nil {
t.Fatalf("unparseable future record should be preserved: %v", err)
}
}
+178
View File
@@ -0,0 +1,178 @@
package cli
import (
"context"
"net"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/serve"
)
func TestRunDispatchesWebCommand(t *testing.T) {
isolateCLIConfigHome(t)
previous := runWebCommand
t.Cleanup(func() { runWebCommand = previous })
var got []string
runWebCommand = func(args []string) int {
got = append([]string(nil), args...)
return 29
}
if code := Run([]string{"web", "--addr", "127.0.0.1:0"}, "test-version"); code != 29 {
t.Fatalf("reasonix web exit code = %d, want 29", code)
}
if strings.Join(got, " ") != "--addr 127.0.0.1:0" {
t.Fatalf("reasonix web args = %q", got)
}
}
func TestHelpListsWebCommand(t *testing.T) {
out := captureStdout(t, func() {
if rc := Run([]string{"help"}, "test-version"); rc != 0 {
t.Fatalf("help rc = %d, want 0", rc)
}
})
if !strings.Contains(out, "reasonix web") {
t.Fatalf("help output missing web command:\n%s", out)
}
}
func TestWebBrowserURLUsesReachableLoopbackAndAuthEntry(t *testing.T) {
tests := []struct {
name string
cfg config.ServeConfig
addr string
want string
}{
{name: "no auth wildcard IPv4", addr: "0.0.0.0:8787", want: "http://127.0.0.1:8787/"},
{name: "no auth wildcard IPv6", addr: "[::]:8788", want: "http://127.0.0.1:8788/"},
{name: "password", cfg: config.ServeConfig{AuthMode: "password", PasswordHash: "$2a$12$test"}, addr: "127.0.0.1:8789", want: "http://127.0.0.1:8789/login"},
{name: "token escaped", cfg: config.ServeConfig{AuthMode: "token", Token: "a token/+"}, addr: "127.0.0.1:8790", want: "http://127.0.0.1:8790/#token=a+token%2F%2B"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), tt.cfg)
if got := webBrowserURL(srv, tt.addr, ""); got != tt.want {
t.Fatalf("webBrowserURL() = %q, want %q", got, tt.want)
}
})
}
}
type acceptGateListener struct {
net.Listener
entered chan struct{}
release chan struct{}
}
func (l *acceptGateListener) Accept() (net.Conn, error) {
select {
case <-l.entered:
default:
close(l.entered)
}
<-l.release
return l.Listener.Accept()
}
func TestRunServeListenerOpensOnlyAfterHTTPResponse(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), config.ServeConfig{})
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ln := &acceptGateListener{Listener: raw, entered: make(chan struct{}), release: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
opened := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- runServeListenerAfterReady(ctx, srv, ln, raw.Addr().String(), func() {
close(opened)
cancel()
})
}()
select {
case <-ln.entered:
case <-time.After(2 * time.Second):
t.Fatal("HTTP server never reached Accept")
}
select {
case <-opened:
t.Fatal("browser callback ran before the HTTP server could respond")
default:
}
close(ln.release)
select {
case <-opened:
case <-time.After(2 * time.Second):
t.Fatal("browser callback did not run after the HTTP server responded")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("runServeListenerAfterReady() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("server did not stop after callback canceled its context")
}
}
func TestRunWebHelpDoesNotOpenBrowser(t *testing.T) {
previous := openBrowserURL
t.Cleanup(func() { openBrowserURL = previous })
openBrowserURL = func(string) error {
t.Fatal("reasonix web --help must not open a browser")
return nil
}
if code := runWeb([]string{"--help"}); code != 0 {
t.Fatalf("runWeb(--help) = %d, want 0", code)
}
}
func TestLaunchWebBrowserInvokesOpenerWithResolvedURL(t *testing.T) {
previous := openBrowserURL
t.Cleanup(func() { openBrowserURL = previous })
var opened string
openBrowserURL = func(raw string) error {
opened = raw
return nil
}
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), config.ServeConfig{})
got, err := launchWebBrowser(srv, "0.0.0.0:8787", "")
if err != nil {
t.Fatal(err)
}
if got != "http://127.0.0.1:8787/" || opened != got {
t.Fatalf("launchWebBrowser() = %q, opened %q", got, opened)
}
}
func TestWebHandoffArgsResumeSameSessionOnDeterministicPort(t *testing.T) {
got := webHandoffArgs("/tmp/session.jsonl", "session", "deepseek/deepseek-v4-flash", "delivery")
want := "--resume /tmp/session.jsonl --model deepseek/deepseek-v4-flash --profile delivery"
if strings.Join(got, " ") != want {
t.Fatalf("webHandoffArgs() = %q, want %q", strings.Join(got, " "), want)
}
}
func TestWebBrowserURLUsesSessionDeepLinkBeforeTokenFragment(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: t.TempDir()})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), config.ServeConfig{AuthMode: "token", Token: "secret"})
got := webBrowserURL(srv, "127.0.0.1:8787", "session with space")
want := "http://127.0.0.1:8787/sessions/session%20with%20space#token=secret"
if got != want {
t.Fatalf("webBrowserURL() = %q, want %q", got, want)
}
}
+1
View File
@@ -260,6 +260,7 @@ type Messages struct {
CmdMouse string // /mouse
CmdReasonLang string // /reasoning-language
CmdHelp string // /help
CmdWeb string // /web
CmdTodo string // /todo
CmdQuit string // /quit (also accepts /exit as hidden alias)
CmdCopy string // /copy
+3
View File
@@ -275,6 +275,7 @@ var English = Messages{
CmdMouse: "toggle in-app mouse capture (off = native terminal selection/right-click)",
CmdReasonLang: "set visible reasoning language",
CmdHelp: "list commands",
CmdWeb: "continue this session in the Web UI",
CmdTodo: "dismiss the task list",
CmdQuit: "exit the session",
CmdCopy: "pick a response to copy to clipboard",
@@ -552,6 +553,7 @@ Usage:
reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] <task>
reasonix run --events-jsonl [--model NAME] <task> emit redacted structured events as JSONL
reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs
reasonix web [--model NAME] [--addr HOST:PORT] [--no-open] start the local Web UI and open it in the default browser
reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] serve over HTTP+SSE (with optional auth)
reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)
reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)
@@ -581,6 +583,7 @@ Examples:
reasonix
reasonix --continue
reasonix --resume provider-config
reasonix web
reasonix run "implement the TODOs in main.go"
reasonix run --model mimo-pro "add unit tests for this function"
reasonix -p "summarize this repository" --output-format json
+3
View File
@@ -276,6 +276,7 @@ var Chinese = Messages{
CmdMouse: "切换鼠标接管(关闭后由终端原生处理选中/右键)",
CmdReasonLang: "设置可见思考语言",
CmdHelp: "查看命令列表",
CmdWeb: "在 Web UI 中继续当前会话",
CmdTodo: "清除任务清单",
CmdQuit: "退出会话",
CmdCopy: "选择回复复制到剪贴板",
@@ -553,6 +554,7 @@ var Chinese = Messages{
reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] <task>
reasonix run --events-jsonl [--model NAME] <task> 输出脱敏结构化事件 JSONL
reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI 代码审查(基于本地 diff)
reasonix web [--model NAME] [--addr HOST:PORT] [--no-open] 启动本地 Web UI 并用默认浏览器打开
reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] 通过 HTTP+SSE 提供服务(支持可选认证)
reasonix acp [--model NAME] 通过 stdio 提供 Agent Client Protocol(也可用:reasonix --acp)
reasonix setup [path] 交互式配置向导;生成 reasonix.toml(及 .env)
@@ -582,6 +584,7 @@ var Chinese = Messages{
reasonix
reasonix --continue
reasonix --resume provider-config
reasonix web
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model mimo-pro "给这个函数补单元测试"
reasonix -p "总结这个仓库" --output-format json
+3
View File
@@ -262,6 +262,7 @@ var ChineseTraditional = Messages{
CmdMouse: "切換滑鼠接管(關閉後由終端原生處理選取/右鍵)",
CmdReasonLang: "設定可見思考語言",
CmdHelp: "檢視命令列表",
CmdWeb: "在 Web UI 中繼續目前工作階段",
CmdTodo: "清除任務清單",
CmdQuit: "退出會話",
CmdCopy: "選擇回覆複製到剪貼簿",
@@ -492,6 +493,7 @@ var ChineseTraditional = Messages{
reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] <task>
reasonix run --events-jsonl [--model NAME] <task> 輸出脫敏結構化事件 JSONL
reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI 程式碼審查(基於本機 diff)
reasonix web [--model NAME] [--addr HOST:PORT] [--no-open] 啟動本機 Web UI 並用預設瀏覽器開啟
reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] 透過 HTTP+SSE 提供服務(支援可選認證)
reasonix acp [--model NAME] 透過 stdio 提供 Agent Client Protocol(也可用:reasonix --acp)
reasonix setup [path] 互動式設定精靈;生成 reasonix.toml(及 .env)
@@ -521,6 +523,7 @@ var ChineseTraditional = Messages{
reasonix
reasonix --continue
reasonix --resume provider-config
reasonix web
reasonix run "把 main.go 裡的 TODO 實現掉"
reasonix run --model mimo-pro "給這個函式補單元測試"
reasonix -p "總結這個倉庫" --output-format json
+75 -3
View File
@@ -9,7 +9,9 @@ import (
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
@@ -218,6 +220,16 @@ func (ag *authGate) middleware(next http.Handler) http.Handler {
return
}
if ag.mode == authToken {
if r.URL.Path == "/auth/token" {
ag.handleTokenBootstrap(w, r)
return
}
// Let the inert shell trade its URL fragment for an HttpOnly cookie
// before API or SSE calls; query-token links use the legacy path below.
if r.URL.Query().Get("token") == "" && tokenBootstrapPublicPath(r) {
next.ServeHTTP(w, r)
return
}
ag.checkToken(w, r, next)
return
}
@@ -226,9 +238,69 @@ func (ag *authGate) middleware(next http.Handler) http.Handler {
})
}
// checkToken validates the token from cookie or query parameter. If the query
// parameter is valid, it sets a cookie and redirects to strip the token from the
// URL (preventing it from leaking via browser history or referrer headers).
func tokenBootstrapPublicPath(r *http.Request) bool {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
if r.URL.Path == "/" || r.URL.Path == "/assets/logo-wordmark.svg" {
return true
}
// Only one non-empty session segment is an inert shell entry point; this
// prevents API-like paths from becoming public in token mode.
const prefix = "/sessions/"
id := strings.TrimPrefix(r.URL.Path, prefix)
return id != r.URL.Path && id != "" && !strings.Contains(id, "/")
}
// handleTokenBootstrap validates a token delivered from the URL fragment by
// the Web shell. The token travels in a bounded JSON body rather than the URL,
// keeping it out of request lines, access logs, browser history, and referrers.
func (ag *authGate) handleTokenBootstrap(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
contentType := r.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
if !strings.EqualFold(strings.TrimSpace(contentType), "application/json") {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
var body struct {
Token string `json:"token"`
}
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if subtle.ConstantTimeCompare([]byte(body.Token), []byte(ag.token)) != 1 {
ag.deny(w, r)
return
}
ag.setAuthCookie(w, r, &http.Cookie{
Name: cookieToken,
Value: ag.token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionDuration.Seconds()),
})
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// checkToken validates the token from a cookie or the legacy query parameter.
// New links use a URL fragment and handleTokenBootstrap; query links remain
// supported so previously shared URLs keep working.
func (ag *authGate) checkToken(w http.ResponseWriter, r *http.Request, next http.Handler) {
// 1. Check cookie first (fast path).
if c, err := r.Cookie(cookieToken); err == nil && strings.TrimSpace(c.Value) != "" {
+124
View File
@@ -0,0 +1,124 @@
package serve
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/config"
)
func TestTokenModeAllowsOnlyBootstrapShellWithoutAuth(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
for _, path := range []string{"/", "/assets/logo-wordmark.svg", "/sessions/session-123"} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode)
}
}
resp, err := http.Get(ts.URL + "/status")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("GET /status = %d, want 401", resp.StatusCode)
}
}
func TestTokenModeDoesNotPublishNestedSessionLikePaths(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
for _, path := range []string{"/sessions/", "/sessions/a/status", "/sessions"} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("GET %s status = %d, want 401", path, resp.StatusCode)
}
}
}
func TestTokenModeFragmentBootstrapSetsHTTPOnlyCookie(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
passed := false
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
passed = true
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.Post(ts.URL+"/auth/token", "application/json", strings.NewReader(`{"token":"secret"}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("bootstrap status = %d, want 204", resp.StatusCode)
}
if passed {
t.Fatal("bootstrap request must not reach the application handler")
}
cookie := findCookie(resp.Cookies(), cookieToken)
if cookie == nil || !cookie.HttpOnly {
t.Fatal("bootstrap response must set an HttpOnly token cookie")
}
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/status", nil)
req.AddCookie(cookie)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !passed {
t.Fatalf("cookie-authenticated request status = %d, passed = %v", resp.StatusCode, passed)
}
}
func TestTokenModeFragmentBootstrapRejectsInvalidRequests(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("invalid bootstrap request reached application handler")
})))
defer ts.Close()
tests := []struct {
name string
contentType string
body string
want int
}{
{name: "wrong token", contentType: "application/json", body: `{"token":"wrong"}`, want: http.StatusUnauthorized},
{name: "non JSON", contentType: "text/plain", body: `{"token":"secret"}`, want: http.StatusUnsupportedMediaType},
{name: "malformed JSON", contentType: "application/json", body: `{`, want: http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := http.Post(ts.URL+"/auth/token", tt.contentType, strings.NewReader(tt.body))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != tt.want {
t.Fatalf("status = %d, want %d", resp.StatusCode, tt.want)
}
})
}
}
+25 -1
View File
@@ -696,6 +696,7 @@ const __T = {
'delete_session': 'Delete Session',
'cannot_delete_active': 'Cannot delete the active session',
'delete_failed': 'Could not delete the session. Check your connection and try again.',
'auth_failed': 'Web authentication failed',
'search_sessions': 'Search sessions',
'active': 'Active',
'use_model': 'Use',
@@ -837,6 +838,7 @@ const __T = {
'delete_session': '删除会话',
'cannot_delete_active': '无法删除当前会话',
'delete_failed': '无法删除会话,请检查连接后重试',
'auth_failed': 'Web 认证失败',
'search_sessions': '搜索会话',
'active': '当前',
'use_model': '使用',
@@ -923,6 +925,24 @@ const welcome = $('#welcome');
const welcomeModel = $('#welcome-model'), welcomeCwd = $('#welcome-cwd');
const slashAnchor = $('#slash-menu-anchor');
// Token links use a URL fragment so the secret never appears in the initial
// HTTP request, browser history, referrers, or access logs. Exchange it for an
// HttpOnly cookie before any API fetch or SSE connection starts.
const __nativeFetch = window.fetch.bind(window);
function bootstrapFragmentToken() {
const fragment = new URLSearchParams(window.location.hash.slice(1));
const token = fragment.get('token');
if(!token)return Promise.resolve();
fragment.delete('token');
const cleanHash=fragment.toString();
window.history.replaceState(null,'',window.location.pathname+window.location.search+(cleanHash?'#'+cleanHash:''));
return __nativeFetch('/auth/token',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(response=>{
if(!response.ok)throw new Error(__('auth_failed')+' (HTTP '+response.status+')');
});
}
const __authReady = bootstrapFragmentToken();
window.fetch = (...args) => __authReady.then(() => __nativeFetch(...args));
// ── state ──
let running = false, planMode = false, bypassMode = false, toolApprovalMode = 'ask', yoloRestoreMode = 'ask';
let goalMode = false, goalActive = false, goalText = '';
@@ -1469,7 +1489,9 @@ function closeSlashMenu(){slashOpen=false;const m=$('#slash-menu');if(m)m.remove
function acceptSlash(){if(!slashOpen)return;const c=slashFiltered[slashIndex];if(c){input.value='/'+c.cmd+' ';}closeSlashMenu();input.focus();}
// ── SSE ──
const es=new EventSource('/events');
let es;
function connectEvents(){
es=new EventSource('/events');
es.onopen=()=>{setConnState('connected');fetchStatus();fetchTodos();};
es.onmessage=ev=>{setConnState('connected');
const e=JSON.parse(ev.data);
@@ -1513,6 +1535,8 @@ es.onerror=()=>{
if(es.readyState===EventSource.CONNECTING){setConnState('reconnecting');}
else{setConnState('disconnected');}
};
}
__authReady.then(connectEvents).catch(error=>{setConnState('disconnected');showNotice(error instanceof Error?error.message:__('auth_failed'),'warn');});
// ── status polling ──
function fetchStatus(){
+10
View File
@@ -40,6 +40,16 @@ const copy=zh?{
badge:'远端',title:'配置远端 Provider',intro:'Reasonix Serve 已可访问,但这台主机尚未保存当前 Provider 的 API Key。',provider:'Provider',model:'模型',credential:'凭据变量',apiKey:'API Key',hint:'密钥只会保存到这台远端主机的 Reasonix 凭据文件。',pendingHint:'API Key 已安全保存。现在只需重试激活 Provider。',save:'保存并打开 Reasonix',retry:'重试激活',saving:'正在保存…',activating:'正在激活…',failed:'保存失败,请重试。'
}:{badge:'Remote',title:'Configure the remote Provider',intro:'This Reasonix Serve is ready, but its selected Provider has no saved API key on this host.',provider:'Provider',model:'Model',credential:'Credential',apiKey:'API Key',hint:"The key is stored only in this remote host's Reasonix credential file.",pendingHint:'The API key is safely stored. Retry Provider activation without entering it again.',save:'Save and open Reasonix',retry:'Retry activation',saving:'Saving…',activating:'Activating…',failed:'Setup failed. Try again.'};
const $=id=>document.getElementById(id);
const __nativeFetch=window.fetch.bind(window);
function bootstrapFragmentToken(){
const fragment=new URLSearchParams(window.location.hash.slice(1)),token=fragment.get('token');
if(!token)return Promise.resolve();
fragment.delete('token');const cleanHash=fragment.toString();
window.history.replaceState(null,'',window.location.pathname+window.location.search+(cleanHash?'#'+cleanHash:''));
return __nativeFetch('/auth/token',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(response=>{if(!response.ok)throw new Error(copy.failed);});
}
const __authReady=bootstrapFragmentToken();
window.fetch=(...args)=>__authReady.then(()=>__nativeFetch(...args));
$('remote-badge').textContent=copy.badge;$('title').textContent=copy.title;$('intro').textContent=copy.intro;$('provider-label').textContent=copy.provider;$('model-label').textContent=copy.model;$('key-label').textContent=copy.credential;$('api-key-label').textContent=copy.apiKey;$('hint').textContent=copy.hint;$('save').textContent=copy.save;
let activationPending=false;
async function load(){
+1 -1
View File
@@ -486,10 +486,10 @@ func (s *Server) Handler() http.Handler {
func (s *Server) HandlerWithCORS(origin string) http.Handler {
return corsMiddleware(s.handler(), origin)
}
func (s *Server) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.index)
mux.HandleFunc("GET /sessions/{id}", s.index)
mux.HandleFunc("GET /assets/logo-wordmark.svg", s.logoWordmark)
mux.HandleFunc("GET /provider-setup", s.providerSetupStatus)
mux.HandleFunc("POST /provider-setup", s.providerSetupSave)
-20
View File
@@ -394,26 +394,6 @@ func TestServeCompactEndpoint(t *testing.T) {
}
}
func TestServeIndexPage(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("index status = %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Errorf("index content-type = %q, want text/html", ct)
}
}
func TestServeIndexDefinesQueryHelpers(t *testing.T) {
html := string(indexHTML)
for _, want := range []string{
+55
View File
@@ -0,0 +1,55 @@
package serve
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
)
func TestServeIndexPageAndSessionDeepLink(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
defer srv.Close()
for _, path := range []string{"/", "/sessions/reserved-session"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s status = %d", path, resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("GET %s content-type = %q, want text/html", path, ct)
}
}
}
func TestServeWebPagesBootstrapFragmentTokenBeforeRequests(t *testing.T) {
for name, html := range map[string]string{
"index": string(indexHTML),
"provider setup": string(providerSetupHTML),
} {
t.Run(name, func(t *testing.T) {
for _, want := range []string{
"new URLSearchParams(window.location.hash.slice(1))",
"'/auth/token'",
"window.history.replaceState",
"window.fetch",
} {
if !strings.Contains(html, want) {
t.Fatalf("page missing fragment-token bootstrap %q", want)
}
}
})
}
if !strings.Contains(string(indexHTML), "__authReady.then(connectEvents)") {
t.Fatal("serve index must delay SSE until fragment authentication completes")
}
}
+7 -7
View File
@@ -166,10 +166,9 @@ reasonix</div>
<p><span class="l-en">Run <code>/init</code> when you want project memory, then leave the session running — context is append-only, so every new turn starts from a cache hit instead of a cold start.</span><span class="l-zh">需要项目记忆时运行 <code>/init</code>,然后让会话持续运行——上下文只追加,每一轮都从缓存命中开始,而不是冷启动。</span></p>
<h2 id="serve"><span class="l-en">Web frontend</span><span class="l-zh">Web 前端</span></h2>
<p><span class="l-en"><code>reasonix serve</code> starts the same local Reasonix engine behind a browser UI. Use it when you want a desktop-style surface without installing the desktop app, when running Reasonix on a remote development box through a tunnel, or when you want a shareable view of a live session.</span><span class="l-zh"><code>reasonix serve</code> 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、在远程开发机上通过 tunnel 使用,或把当前会话临时共享给浏览器查看。</span></p>
<div class="codeblock codeblock--copy"><button data-copy="cd your-project && reasonix serve">Copy</button>cd your-project
reasonix serve
<span class="c"># open http://127.0.0.1:8787</span></div>
<p><span class="l-en"><code>reasonix web</code> starts the same local Reasonix engine and opens its browser UI. Inside the CLI, <code>/web</code> hands the current session to that UI. Keep <code>reasonix serve</code> for supervisors, tunnels, reverse proxies, and remote development boxes.</span><span class="l-zh"><code>reasonix web</code> 会启动同一个本地 Reasonix 引擎并打开浏览器 UI;在 CLI 中执行 <code>/web</code> 可把当前会话交接到该界面。进程托管、tunnel、反向代理和远程开发机继续使用 <code>reasonix serve</code>。</span></p>
<div class="codeblock codeblock--copy"><button data-copy="cd your-project && reasonix web">Copy</button>cd your-project
reasonix web</div>
<div class="web-panel">
<span class="desktop-kicker"><span class="l-en">Browser UI</span><span class="l-zh">浏览器界面</span></span>
<h3><span class="l-en">Chat, approvals, goals, todos, and balance</span><span class="l-zh">聊天、审批、Goal、Todo 与余额</span></h3>
@@ -179,8 +178,8 @@ reasonix serve
<li><span class="l-en">Show provider balance when configured; pass <code>--model</code>, <code>--max-steps</code>, or <code>--resume</code> for one-off launches.</span><span class="l-zh">已配置 provider 时显示余额;临时启动可传 <code>--model</code>、<code>--max-steps</code> 或 <code>--resume</code>。</span></li>
</ul>
</div>
<p><span class="l-en">The default listen address is <code>127.0.0.1:8787</code> with <code>auth_mode = "none"</code>. Keep that for local-only use. If the server is reachable from another machine, enable auth before sharing the URL.</span><span class="l-zh">默认监听 <code>127.0.0.1:8787</code>,认证模式是 <code>auth_mode = "none"</code>。这个默认值只适合本机使用;如果其他机器能访问,请先开启认证再分享 URL。</span></p>
<div class="codeblock codeblock--copy"><button data-copy="reasonix serve --auth token">Copy</button><span class="c"># token auth prints a share URL with ?token=...</span>
<p><span class="l-en"><code>reasonix web</code> starts at <code>127.0.0.1:8787</code>, walks upward when the port is busy, and defaults to an automatically generated token. <code>reasonix serve</code> keeps its config-driven <code>auth_mode = "none"</code> default; enable auth before making Serve reachable from another machine.</span><span class="l-zh"><code>reasonix web</code> 从 <code>127.0.0.1:8787</code> 开始监听,端口占用时自动递增,并默认生成 Token。<code>reasonix serve</code> 保持由配置决定的 <code>auth_mode = "none"</code> 默认值;如果其他机器可以访问 Serve,请先开启认证。</span></p>
<div class="codeblock codeblock--copy"><button data-copy="reasonix serve --auth token">Copy</button><span class="c"># token auth prints a share URL with #token=...</span>
reasonix serve --auth token
reasonix serve --addr 0.0.0.0:8787 --auth token
@@ -282,7 +281,8 @@ reasonix acp --profile delivery <span class="c"># economy | balanced | delivery<
reasonix <span class="c"># interactive session, prompts before writes</span>
reasonix run "&lt;task&gt;" <span class="c"># headless Ask; writers fail closed</span>
reasonix run -y "&lt;task&gt;" <span class="c"># opt into unattended ordinary writes</span>
reasonix serve <span class="c"># browser UI at 127.0.0.1:8787</span>
reasonix web <span class="c"># open the local browser UI</span>
reasonix serve <span class="c"># supervised or remote HTTP+SSE frontend</span>
reasonix bot doctor <span class="c"># check saved IM bot connections</span></div>
<div class="codeblock">reasonix config compact-ratio <span class="c"># effective value + source</span>
reasonix config compact-ratio 75 <span class="c"># user-global default</span>