mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #8059 from SivanCola/feature/web-cli-handoff
feat(cli): add first-class Web handoff / 增加 CLI Web 会话交接
This commit is contained in:
30 files changed
+1719
-162
No files matched your search
+31
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(){
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 "<task>" <span class="c"># headless Ask; writers fail closed</span>
|
||||
reasonix run -y "<task>" <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>
|
||||
|
||||
Reference in new issue
Block a user