fix(serve): authenticate by default; approvals require the launch token

`reasonix serve` now generates a per-launch token unless `--auth` or
`[serve].auth_mode` chooses otherwise. `none` remains an explicit opt-out for
deployments fronted by their own authentication: reads stay open to loopback
and bound names only (421 `serve.host_rejected` otherwise), and every
state-changing request, approvals included, needs the launch token (bearer
header or the /auth/token cookie) and otherwise refuses with
`auth.launch_token_required`. A request the loopback gate, a paired-device
gate or the in-process transport already admitted counts as holding it, so
Studio and the TUI keep working unchanged. Token mode also accepts the bearer
header.

The token never reaches the terminal: serve writes it to a 0600 file under the
remote state directory and prints the path, or names the --token-file of a
managed launch. Runtime sandboxes and read tools deny that directory and every
`--token-file`, `--provider-broker-file` and `--provider-broker-token-file`.

[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
This commit is contained in:
esengine committed 2026-09-27 17:35:46 -07:00
1 parent fba2fbad2a
commit 7a562055e5
91 files changed
+1094 -272

No files matched your search

+4 -3
View File
@@ -56,9 +56,10 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the default per-launch token, the launch
token every state-changing request requires when authentication is opted
out, localhost binding assumptions, JSON-only state-changing requests, and
CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
+2
View File
@@ -320,6 +320,8 @@ const SAID: Record<string, string> = {
"loopback.origin_rejected": "该页面不属于 Studio,无法对其操作",
"loopback.unauthorized": "缺少本次启动的凭据,请重新打开 Studio 后重试",
"loopback.misconfigured": "本机通道未建立,请重新打开 Studio 后重试",
"serve.host_rejected": "该服务未开启认证,只接受发往本机或其监听地址的请求",
"auth.launch_token_required": "该服务未开启认证,修改与审批需要本次启动的令牌,请用启动时打印的链接打开",
};
/** Reason is what a refused request answers with. `error` is English fallback
+24 -6
View File
@@ -128,7 +128,7 @@ allow = ["Bash(go test:*)"] # never prompted
# forbid_read = ["${HOME}/.ssh"] # paths the agent must not read or list
[serve]
auth_mode = "none" # none|token|password; use auth before binding beyond localhost
# auth_mode = "token" # token (default)|password|none; none still requires the launch token for changes
# token = "" # optional fixed token; empty token mode generates one at startup
# password_hash = "" # bcrypt hash generated with reasonix serve --hash-password --password '...'
# behind_proxy = false # true only behind a trusted reverse proxy
@@ -254,11 +254,29 @@ 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:
`reasonix serve` also defaults to a freshly generated per-launch token unless
`[serve].auth_mode` or `--auth` says otherwise.
- `--auth none` (or `auth_mode = "none"`) is an explicit opt-out for a Serve
fronted by its own authentication. Reads are open to whoever reaches a
loopback or bound address; other Host names get 421 `serve.host_rejected`.
- Every state-changing request, approvals included, still needs the launch
token: `Authorization: Bearer <token>`, or the cookie the printed
`approvals:` link sets. Without it the answer is 403
`auth.launch_token_required`.
- Serve writes the token to a 0600 file under `<Reasonix home>/remote/` and
prints only its path; append `#token=<file contents>` to the printed link.
A managed launch with `--token-file` names that file instead. The phone QR
code a terminal shows in token mode still carries the token.
- Prefer `--token-file` over `--token`: argv is visible to other processes,
sandboxed ones included. A plaintext `[serve].token` in the global config is
readable from inside the sandbox; keep the secret in a file.
- On macOS and Linux the OS sandbox denies the remote state directory and every
token file. Windows has no bash sandbox, so there only the read tools refuse.
- `[serve]` is read from the user config only; a project `reasonix.toml`
cannot set it.
Choose the mode explicitly when binding outside loopback:
```bash
reasonix serve --auth token
+11 -4
View File
@@ -136,7 +136,7 @@ allow = ["Bash(go test:*)"] # 从不询问
# forbid_read = ["${HOME}/.ssh"] # agent 不可读取或列出的路径
[serve]
auth_mode = "none" # none|token|password;绑定到非 localhost 前请先开启认证
# auth_mode = "token" # token(默认)|password|none;none 下修改与审批仍需启动令牌
# token = "" # 可选固定 token;token 模式为空时启动时自动生成
# password_hash = "" # 用 reasonix serve --hash-password --password '...' 生成
# behind_proxy = false # 只在可信反向代理后方设为 true
@@ -243,9 +243,16 @@ reasonix web
按 Ctrl-C 停止。
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
请先开启认证再分享 URL:
`reasonix serve` 在未配置 `[serve].auth_mode` 且未传 `--auth` 时,同样默认使用每次启动新生成的 Token。
- `--auth none`(或 `auth_mode = "none"`)是给自行在前面加认证的部署用的显式退出,读取接口只对发往本机或监听地址的请求开放,其他 Host 返回 421 `serve.host_rejected`。
- 所有会改变状态的请求(包括审批)仍需启动令牌:`Authorization: Bearer <token>`,或启动时打印的 `approvals:` 链接设置的 Cookie;否则返回 403 `auth.launch_token_required`。
- serve 把令牌写进 `<Reasonix home>/remote/` 下 0600 文件,终端只打印路径,在链接后拼上 `#token=<文件内容>` 即可;带 `--token-file` 的托管启动打印该文件路径。token 模式下终端显示的手机二维码仍含令牌。
- 优先用 `--token-file` 而不是 `--token`:命令行参数对其他进程可见,沙盒内的进程也能看到。全局配置里明文的 `[serve].token` 在沙盒内可读,密钥请放文件。
- macOS 和 Linux 的系统沙盒会拒绝读取该状态目录和所有令牌文件;Windows 没有 bash 沙盒,只有读文件工具会拒绝。
- `[serve]` 只从用户配置读取,项目里的 `reasonix.toml` 不能设置它。
绑定到非 loopback 地址时请显式选择认证模式:
```bash
reasonix serve --auth token
+11 -5
View File
@@ -1162,7 +1162,7 @@ ask = [] # force a prompt even if otherwise al
# forbid_read = ["${HOME}/.ssh"] # paths read/list/search tools and sandboxed bash may not inspect
[serve]
auth_mode = "none" # none|token|password; use auth before binding beyond localhost
# auth_mode = "token" # token (default)|password|none; none still requires the launch token for changes
# token = "" # optional fixed token; empty token mode generates one at startup
# password_hash = "" # bcrypt hash generated with reasonix serve --hash-password --password '...'
# behind_proxy = false # trust X-Forwarded-* only behind a trusted reverse proxy
@@ -1207,10 +1207,16 @@ unknown values fall back to `bar`. It applies to the Bubble Tea CLI/TUI
textarea only, while desktop and browser inputs keep their platform-native
cursor behavior.
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. The
default `auth_mode = "none"` is intended for the loopback default
`127.0.0.1:8787`; deployments reachable from another machine must use `token` or
`password`. Password mode requires either a startup `--password` or a stored
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. An unset
`auth_mode` generates a per-launch token. `none` is an explicit opt-out for a
Serve fronted by other authentication; reads stay open, while every
state-changing request, approvals included, still requires the launch token and
otherwise refuses with `auth.launch_token_required`.
The token goes to a 0600 file under `<Reasonix home>/remote/`, not the
terminal. `[serve]` is read from the user config only.
Password mode requires either a startup `--password` or a stored
bcrypt `password_hash`. `behind_proxy` must stay false unless the server is
behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto`
headers.
+3
View File
@@ -46,6 +46,9 @@ this gate, and the two cannot share one cookie — a configured `auth_mode =
`cmd/reasonix-studio-host` strips authentication out of the config it hands the
hub for exactly that reason, and a test pins it.
The hub's auth-disabled mode still holds mutations to a launch token; a request
this gate admitted counts as holding it.
## 3. One transport
Ordinary business runs over HTTP and SSE, the same surface a browser gets. No
+1
View File
@@ -304,6 +304,7 @@ func RuntimeForbidReadRoots(cfg *config.Config, root string) []string {
secrets.RegisterCredentialEnvKeys(cfg.CredentialEnvNames())
base := cfg.ForbidReadRootsForRoot(root)
base = appendUniquePaths(base, secrets.ForbiddenReadPaths(cfg.Secrets.ProtectSensitiveFiles)...)
base = appendUniquePaths(base, append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir())...)
credentialPath := strings.TrimSpace(cfg.Roots().UserCredentialsPath())
if credentialPath == "" {
return append([]string(nil), base...)
+2 -1
View File
@@ -4002,7 +4002,8 @@ func TestRuntimeForbidReadRootsAddsOnlyGlobalCredentialFile(t *testing.T) {
cfg := config.Default()
cfg.Sandbox.ForbidRead = []string{configured}
withoutCredentials := RuntimeForbidReadRoots(cfg, ".")
if !reflect.DeepEqual(withoutCredentials, []string{configured}) {
hostSecrets := append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir())
if !reflect.DeepEqual(withoutCredentials, appendUniquePaths([]string{configured}, hostSecrets...)) {
t.Fatalf("roots without global credentials = %v", withoutCredentials)
}
@@ -0,0 +1,67 @@
package boot
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
func TestRuntimeForbidReadRootsCoverServeLaunchTokens(t *testing.T) {
t.Setenv("REASONIX_HOME", filepath.Join(isolateConfigHome(t), "reasonix-home"))
tokenFile := filepath.Join(robustTempDir(t), "serve.token")
secrets.RegisterHostSecretPath(tokenFile)
got := RuntimeForbidReadRoots(config.Default(), ".")
for _, want := range []string{config.RemoteStateDir(), tokenFile} {
if !pathListContains(got, want) {
t.Fatalf("runtime forbid roots = %v, missing %s", got, want)
}
}
}
func TestSandboxedShellCannotReadServeLaunchTokens(t *testing.T) {
if runtime.GOOS == "windows" || !sandbox.Available() {
t.Skip("OS sandbox unavailable")
}
t.Setenv("REASONIX_HOME", filepath.Join(isolateConfigHome(t), "reasonix-home"))
remoteDir := config.RemoteStateDir()
if err := os.MkdirAll(remoteDir, 0o700); err != nil {
t.Fatal(err)
}
const secret = "launch-token-sentinel"
remoteToken := filepath.Join(remoteDir, "serve-demo.token")
flagToken := filepath.Join(robustTempDir(t), "serve.token")
visible := filepath.Join(robustTempDir(t), "visible.txt")
for _, path := range []string{remoteToken, flagToken, visible} {
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
t.Fatal(err)
}
}
secrets.RegisterHostSecretPath(flagToken)
work := robustTempDir(t)
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, ForbidReadRoots: RuntimeForbidReadRoots(config.Default(), work), Network: true}
read := func(path string) (string, error) {
argv, wrapped := sandbox.Command(spec, sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "cat "+path)
if !wrapped {
t.Fatal("sandbox did not wrap the command")
}
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
return string(out), err
}
if out, err := read(visible); err != nil || !strings.Contains(out, secret) {
t.Fatalf("control read = %q, %v; the sandbox must still read ordinary files", out, err)
}
for _, path := range []string{remoteToken, flagToken} {
if out, err := read(path); err == nil || strings.Contains(out, secret) {
t.Fatalf("sandboxed read of %s = %q, %v; want denied", path, out, err)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package secrets
import (
"path/filepath"
"slices"
"strings"
"sync"
)
var hostSecrets struct {
sync.Mutex
paths []string
}
// RegisterHostSecretPath records a file this process reads a secret from, such
// as serve's --token-file, so runtime sandboxes and read tools deny it.
func RegisterHostSecretPath(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
hostSecrets.Lock()
defer hostSecrets.Unlock()
if slices.Contains(hostSecrets.paths, path) {
return
}
hostSecrets.paths = append(hostSecrets.paths, path)
}
// HostSecretPaths lists every path passed to RegisterHostSecretPath.
func HostSecretPaths() []string {
hostSecrets.Lock()
defer hostSecrets.Unlock()
return append([]string(nil), hostSecrets.paths...)
}
+5 -5
View File
@@ -127,7 +127,7 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
globalCLI := cfg.CLI
globalSecrets := cfg.Secrets
globalSandbox := holdUserSandbox(cfg.Sandbox)
globalRemote, globalStorage := cfg.Remote.Clone(), maps.Clone(cfg.Storage)
globalRemote, globalStorage, globalServe := cfg.Remote.Clone(), maps.Clone(cfg.Storage), cfg.Serve
globalDesktopLanguage := cfg.Desktop.Language
globalPricingCurrency := cfg.Desktop.Currency
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
@@ -157,10 +157,10 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
cfg.Secrets = globalSecrets
// Sandbox grants are the same kind of control (see heldSandbox).
globalSandbox.restore(&cfg.Sandbox)
// Remote SSH hosts and storage locations are equally user-global: a cloned
// repo must not inject hosts, jump chains, or port forwards, nor redirect
// where this machine keeps its transcripts, catalogs, and checkouts.
cfg.Remote, cfg.Storage = globalRemote, globalStorage
// Remote hosts, storage locations and serve authentication are user-global:
// a repo must not inject hosts or forwards, redirect where transcripts live,
// or choose serve's launch token, auth mode, or trust in forwarded headers.
cfg.Remote, cfg.Storage, cfg.Serve = globalRemote, globalStorage, globalServe
// Desktop language and pricing currency are user-level regional preferences.
// A repository must not be able to alter how the user's spend is shown.
cfg.Desktop.Language = globalDesktopLanguage
+6 -2
View File
@@ -293,8 +293,12 @@ func ReasonixHomeDir() string { return processRoots().Home() }
// RemoteStateDir is local state for the remote-SSH module (the managed
// known_hosts file, cached host metadata): <Reasonix home>/remote. Routed
// through the home resolver so REASONIX_HOME isolation holds.
func RemoteStateDir() string {
home := processRoots().Home()
func RemoteStateDir() string { return processRoots().RemoteStateDir() }
// RemoteStateDir is <home>/remote under these roots. A serve launched over SSH
// keeps its launch token there, so runtime sandboxes deny reading it.
func (r Roots) RemoteStateDir() string {
home := r.Home()
if strings.TrimSpace(home) == "" {
return ""
}
@@ -0,0 +1,25 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// Serve authentication is user-global: a repository or the agent writing
// inside the workspace cannot choose the launch token or the auth mode.
func TestProjectConfigCannotSetServeAuthentication(t *testing.T) {
isolateUserConfigHome(t)
root := t.TempDir()
body := "[serve]\nauth_mode = \"none\"\ntoken = \"attacker-known\"\nbehind_proxy = true\n"
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadForRootReadOnly(root)
if err != nil {
t.Fatal(err)
}
if cfg.Serve.Token != "" || cfg.Serve.AuthMode != "" || cfg.Serve.BehindProxy {
t.Fatalf("project reasonix.toml reached [serve]: %+v", cfg.Serve)
}
}
+3
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/provider"
"reasonix/internal/model/providerbroker"
)
@@ -40,6 +41,8 @@ func (b brokerFlags) resolver() (provider.Resolver, error) {
if addr == "" && file == "" {
return nil, nil
}
secrets.RegisterHostSecretPath(file)
secrets.RegisterHostSecretPath(tokenFile)
if addr != "" && file != "" {
return nil, fmt.Errorf("--provider-broker and --provider-broker-file name the broker twice; give one")
}
+37 -17
View File
@@ -5,6 +5,7 @@ import (
"errors"
"flag"
"fmt"
"io"
"io/fs"
"net"
"net/http"
@@ -16,6 +17,7 @@ import (
"syscall"
"reasonix/internal/base/i18n"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/surface"
@@ -36,6 +38,7 @@ type serveHost interface {
RunGracefulListener(ctx context.Context, ln net.Listener) error
StartRecoveryGC(ctx context.Context)
EnableProviderSetupForListener(addr string) bool
AllowHost(host string)
}
// runServe exposes the controller's HTTP and SSE frontend.
@@ -69,6 +72,9 @@ type serveFrontendOptions struct {
// started it. It holds none of its own, so anything reading them here fails
// by design rather than because something is wrong.
brokered bool
// launchTokenPath names the file holding the launch token, so the
// terminal shows a path rather than the secret.
launchTokenPath string
}
type serveFrontendResources struct {
@@ -142,6 +148,15 @@ func runServeFrontend(ctrl *control.Controller, srv serveHost, cfg config.ServeC
}
defer resources.release(false)
srv.EnableProviderSetupForListener(resources.displayAddr)
if public, err := url.Parse(strings.TrimSpace(opts.publicURL)); err == nil && public.Hostname() != "" {
srv.AllowHost(public.Hostname())
}
if srv.AuthMode() == "none" || srv.AuthMode() == "token" {
if opts.launchTokenPath, err = launchTokenLocation(srv.AuthToken(), opts, resources); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
}
reportServeFrontend(ctrl, srv, cfg, resources.displayAddr, opts)
// Not for a brokered kernel: its credentials live on the machine that
// started it, so this fails by design into a log only opened during a
@@ -158,18 +173,8 @@ func reportServeFrontend(ctrl *control.Controller, srv serveHost, cfg config.Ser
if public := strings.TrimRight(strings.TrimSpace(opts.publicURL), "/"); public != "" {
origin = public
}
// Supervised Serve already owns the token file, so avoid logging its value.
reportServeAuth(os.Stdout, srv.AuthMode(), origin, opts.launchTokenPath)
supervised := opts.portFile != "" && opts.tokenFile != ""
if srv.AuthMode() == "token" {
fmt.Println(" auth: token")
if supervised {
fmt.Printf(" share: %s/ (token in %s)\n", origin, opts.tokenFile)
} else {
fmt.Printf(" share: %s/#token=%s\n", origin, url.QueryEscape(srv.AuthToken()))
}
} else if srv.AuthMode() == "password" {
fmt.Printf(" auth: password (login at %s/login)\n", origin)
}
if !supervised && isTTY(os.Stdout) && termrender.ANSIConsoleReady() {
reportShareQR(os.Stdout, srv.AuthMode(), srv.AuthToken(), address, opts.publicURL)
}
@@ -178,6 +183,21 @@ func reportServeFrontend(ctrl *control.Controller, srv serveHost, cfg config.Ser
}
}
// reportServeAuth names the file holding the launch token, never the token: a
// sandboxed command can read back a terminal multiplexer's scrollback.
func reportServeAuth(w io.Writer, mode, origin, tokenPath string) {
switch mode {
case "token":
fmt.Fprintln(w, " auth: token")
fmt.Fprintf(w, " share: %s/#token=<token> (token in %s)\n", origin, tokenPath)
case "password":
fmt.Fprintf(w, " auth: password (login at %s/login)\n", origin)
case "none":
fmt.Fprintln(w, " auth: none (changes and approvals still require the launch token)")
fmt.Fprintf(w, " approvals: %s/#token=<token> (token in %s)\n", origin, tokenPath)
}
}
func startServeBalanceDiagnostics(ctrl *control.Controller) {
go func() {
switch r := ctrl.Balance(context.Background()); {
@@ -211,9 +231,9 @@ func serveFrontendLoop(ctrl *control.Controller, srv serveHost, resources *serve
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)
fmt.Fprintf(os.Stderr, " browser: could not open %s — %v\n", withoutFragment(browserURL), err)
} else {
fmt.Printf(" browser: %s\n", browserURL)
fmt.Printf(" browser: %s\n", withoutFragment(browserURL))
}
})
} else {
@@ -241,7 +261,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
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)"
authHelp := "auth mode: none, token, or password (default: config, else a generated token; none still requires the token for changes)"
if opts.command == "web" {
authHelp = "auth mode: none, token, or password (default: generated token)"
}
@@ -308,9 +328,8 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
// Build serve config, merging CLI flags over config file.
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.
// Both commands default to a freshly generated token; an explicit --auth
// always wins, and serve also honours a configured auth_mode.
if *auth != "" {
serveCfg.AuthMode = *auth
}
@@ -324,6 +343,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
return 1
}
serveCfg.Token = tok
secrets.RegisterHostSecretPath(*tokenFile)
}
if *behindProxy {
serveCfg.BehindProxy = true
@@ -0,0 +1,53 @@
package cli
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/base/fileutil"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
)
// writeLaunchTokenFile leaves the token an auth-disabled serve's changes need
// in a 0600 file under the remote state directory, which runtime sandboxes
// deny. The terminal is no place for it: a sandboxed command can read back a
// multiplexer's scrollback.
func writeLaunchTokenFile(dir, token string) (string, error) {
if strings.TrimSpace(dir) == "" {
return "", errors.New("cannot store the launch token: Reasonix home is empty")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("launch-%d.token", os.Getpid()))
_ = os.Remove(path)
if err := fileutil.AtomicCreateFile(path, []byte(token+"\n"), 0o600); err != nil {
return "", err
}
secrets.RegisterHostSecretPath(path)
return path, nil
}
// launchTokenLocation is where an auth-disabled serve tells the operator to
// find its launch token: the --token-file it was given, or a file it writes.
func launchTokenLocation(token string, opts serveFrontendOptions, resources *serveFrontendResources) (string, error) {
if opts.tokenFile != "" {
return opts.tokenFile, nil
}
path, err := writeLaunchTokenFile(config.RemoteStateDir(), token)
if err != nil {
return "", err
}
resources.artifacts = append(resources.artifacts, path)
return path, nil
}
// withoutFragment drops a #token= fragment before a URL reaches the terminal.
func withoutFragment(raw string) string {
before, _, _ := strings.Cut(raw, "#")
return before
}
@@ -0,0 +1,60 @@
package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
"reasonix/internal/session/control"
)
func TestServeKeepsLaunchTokenOffTheTerminal(t *testing.T) {
for _, mode := range []string{"none", "token"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
ctrl := control.New(control.Options{SessionDir: testenv.TempDir(t)})
t.Cleanup(ctrl.Close)
srv := serve.New(ctrl, serve.NewBroadcaster(), config.ServeConfig{AuthMode: mode})
resources := &serveFrontendResources{}
opts := serveFrontendOptions{command: "serve"}
path, err := launchTokenLocation(srv.AuthToken(), opts, resources)
if err != nil {
t.Fatal(err)
}
defer resources.release(false)
if filepath.Dir(path) != config.RemoteStateDir() {
t.Fatalf("launch token file %s is outside the sandbox-denied %s", path, config.RemoteStateDir())
}
data, err := os.ReadFile(path)
if err != nil || strings.TrimSpace(string(data)) != srv.AuthToken() {
t.Fatalf("launch token file = %q, %v", data, err)
}
if info, err := os.Stat(path); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
t.Fatalf("launch token file mode = %v, %v; want 0600", info.Mode().Perm(), err)
}
var report strings.Builder
reportServeAuth(&report, srv.AuthMode(), "http://127.0.0.1:8787", path)
out := report.String()
if strings.Contains(out, srv.AuthToken()) || !strings.Contains(out, path) {
t.Fatalf("serve report printed the token or omitted its file:\n%s", out)
}
resources.release(false)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("launch token file outlived the serve: %v", err)
}
})
}
}
func TestManagedServePointsAtItsTokenFile(t *testing.T) {
resources := &serveFrontendResources{}
path, err := launchTokenLocation("secret", serveFrontendOptions{tokenFile: "/run/serve.token"}, resources)
if err != nil || path != "/run/serve.token" || len(resources.artifacts) != 0 {
t.Fatalf("managed launch token location = %q %v artifacts=%v", path, err, resources.artifacts)
}
}
+3
View File
@@ -65,6 +65,9 @@ func webBrowserURL(srv serveHost, addr, sessionID string) string {
}
return base + "/login"
default:
if token := srv.AuthToken(); token != "" {
return base + entryPath + "#token=" + url.QueryEscape(token)
}
return base + entryPath
}
}
+6 -1
View File
@@ -21,8 +21,13 @@ import (
"reasonix/internal/state/store"
)
// serveConfigWithCommandDefaults authenticates serve with a per-launch token
// unless the user opted out by flag or config; `web` ignores a configured none.
func serveConfigWithCommandDefaults(command string, authExplicit bool, cfg config.ServeConfig) config.ServeConfig {
if command == "web" && !authExplicit {
if authExplicit {
return cfg
}
if command == "web" || strings.TrimSpace(cfg.AuthMode) == "" {
cfg.AuthMode = "token"
}
return cfg
+3 -1
View File
@@ -26,7 +26,9 @@ func TestServeConfigWithCommandDefaults(t *testing.T) {
{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: ""},
{name: "serve generates a launch token by default", command: "serve", want: "token"},
{name: "serve configured none is an explicit opt-out", command: "serve", configured: "none", want: "none"},
{name: "serve explicit auth wins", command: "serve", authExplicit: true, configured: "none", want: "none"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+9 -5
View File
@@ -56,8 +56,8 @@ func TestWebBrowserURLUsesReachableLoopbackAndAuthEntry(t *testing.T) {
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: "no auth wildcard IPv4", cfg: config.ServeConfig{AuthMode: "none"}, addr: "0.0.0.0:8787", want: "http://127.0.0.1:8787/#token="},
{name: "no auth wildcard IPv6", cfg: config.ServeConfig{AuthMode: "none"}, addr: "[::]:8788", want: "http://127.0.0.1:8788/#token="},
{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"},
}
@@ -66,8 +66,12 @@ func TestWebBrowserURLUsesReachableLoopbackAndAuthEntry(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: testenv.TempDir(t)})
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)
want := tt.want
if tt.cfg.AuthMode == "none" {
want += srv.AuthToken()
}
if got := webBrowserURL(srv, tt.addr, ""); got != want {
t.Fatalf("webBrowserURL() = %q, want %q", got, want)
}
})
}
@@ -161,7 +165,7 @@ func TestLaunchWebBrowserInvokesOpenerWithResolvedURL(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got != "http://127.0.0.1:8787/" || opened != got {
if got != "http://127.0.0.1:8787/#token="+srv.AuthToken() || opened != got {
t.Fatalf("launchWebBrowser() = %q, opened %q", got, opened)
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ func accountServer(t *testing.T, granted bool) *httptest.Server {
if granted {
s.AllowAccountAuth()
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
return srv
}
@@ -19,7 +19,7 @@ func adjudicationServer(t *testing.T) (*httptest.Server, *control.Controller) {
t.Cleanup(ctrl.Close)
ctrl.SetSessionPath(filepath.Join(testenv.TempDir(t), "s.jsonl"))
s := New(ctrl, NewBroadcaster(), config.ServeConfig{})
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
return srv, ctrl
}
+3 -3
View File
@@ -45,7 +45,7 @@ func TestARemoteDialRunsUnderTheRequestThatAskedForIt(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
link := &blockingAttacher{dialing: make(chan context.Context, 1)}
h := NewHub(HubOptions{Remote: link})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
cases := []struct {
@@ -241,7 +241,7 @@ func TestTheAnswerEndpointNamesWhatWentWrong(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
b := NewAskBroker(nil)
h := NewHub(HubOptions{Asks: b})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
ctx, cancel := context.WithCancel(context.Background())
@@ -315,7 +315,7 @@ func TestAnsweringAQuestionLetsTheDialFinishAndThePaneOpen(t *testing.T) {
Asks: asks,
Remote: &askingAttacher{asks: asks, endpoint: RemoteEndpoint{Addr: far.Listener.Addr().String(), Token: "remote-secret"}},
})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
const operation = "op-the-client-named"
+2 -2
View File
@@ -25,7 +25,7 @@ func TestAttachmentSavesAndReturnsATurnReference(t *testing.T) {
dir := testenv.TempDir(t)
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
var buf bytes.Buffer
@@ -58,7 +58,7 @@ func TestAttachmentSavesAndReturnsATurnReference(t *testing.T) {
func TestAttachmentRefusesWhatIsNotAnImage(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: testenv.TempDir(t)})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
junk, _ := json.Marshal(map[string]string{"mime": "image/png", "data": base64.StdEncoding.EncodeToString([]byte("not an image"))})
+21 -9
View File
@@ -34,7 +34,7 @@ type authMode int
const (
authInvalid authMode = iota // invalid config; deny all requests
authNone // no authentication (default, backward-compatible)
authNone // explicit opt-out; mutations still need the launch token
authToken // pre-shared token in URL or cookie
authPassword // login page with bcrypt password
)
@@ -125,12 +125,14 @@ type authGate struct {
sessKey []byte // HMAC key for session signing (password mode, generated at startup)
behindProxy bool // trust X-Forwarded-For / X-Forwarded-Proto headers
rateLimit *rateLimit // per-IP rate limiter for /login
hosts *hostAllowlist
}
// newAuthGate creates the auth middleware from the serve config. For token mode
// without a configured token, it generates a random one.
func newAuthGate(cfg config.ServeConfig) *authGate {
ag := &authGate{
hosts: &hostAllowlist{},
rateLimit: newRateLimit(),
behindProxy: cfg.BehindProxy,
}
@@ -152,6 +154,10 @@ func newAuthGate(cfg config.ServeConfig) *authGate {
ag.sessKey = sessionKeyForPasswordHash(ag.passwordHash)
default:
ag.mode = authNone
ag.token = strings.TrimSpace(cfg.Token)
if ag.token == "" {
ag.token = generateToken()
}
}
return ag
}
@@ -160,7 +166,8 @@ func newAuthGate(cfg config.ServeConfig) *authGate {
// a host that must authenticate to another machine's kernel on a page's behalf.
const TokenCookie = cookieToken
// Token returns the shared token (empty if not in token mode).
// Token returns the launch token: the shared token in token mode, the token
// mutations need when authentication is off, empty in password mode.
func (ag *authGate) Token() string { return ag.token }
// Mode returns the auth mode name as a string.
@@ -214,6 +221,10 @@ func (ag *authGate) middleware(next http.Handler) http.Handler {
return
}
if ag.mode == authNone {
if r.URL.Path == "/auth/token" {
ag.handleTokenBootstrap(w, r)
return
}
next.ServeHTTP(w, r)
return
}
@@ -306,12 +317,10 @@ func (ag *authGate) handleTokenBootstrap(w http.ResponseWriter, r *http.Request)
// 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) != "" {
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(ag.token)) == 1 {
next.ServeHTTP(w, r)
return
}
// 1. Cookie or Authorization bearer (fast path).
if ag.presentsLaunchToken(r) {
next.ServeHTTP(w, r)
return
}
// 2. Check query parameter.
@@ -686,8 +695,11 @@ func isLoopbackHost(hostport string) bool {
// trusted LAN or reverse-proxy setup, but users should see the risk explicitly.
func PlainHTTPAuthWarning(cfg config.ServeConfig, addr string) string {
mode, err := NormalizeAuthMode(cfg.AuthMode)
if err != nil || mode == "none" || isLoopbackHost(addr) {
if err != nil || isLoopbackHost(addr) {
return ""
}
if mode == "none" {
return "warning: serve is listening on non-loopback HTTP with authentication disabled; anyone on this network can read sessions and history, and the launch token that changes need crosses it in clear — bind to 127.0.0.1 or set serve.auth_mode"
}
return "warning: authenticated serve is listening on non-loopback HTTP; use HTTPS via a trusted reverse proxy or bind to 127.0.0.1 for local-only access"
}
+4 -4
View File
@@ -46,8 +46,8 @@ func TestAuthGateModeNone(t *testing.T) {
if ag.Mode() != "none" {
t.Errorf("mode = %q, want none", ag.Mode())
}
if ag.Token() != "" {
t.Errorf("token = %q, want empty", ag.Token())
if ag.Token() == "" {
t.Error("auth-disabled gate has no launch token for mutations")
}
// In none mode, requests pass through.
@@ -731,8 +731,8 @@ func TestAuthCookieSecurePolicy(t *testing.T) {
}
func TestPlainHTTPAuthWarning(t *testing.T) {
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "none"}, "0.0.0.0:8787"); got != "" {
t.Fatalf("none auth warning = %q, want empty", got)
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "none"}, "0.0.0.0:8787"); !strings.Contains(got, "read sessions") {
t.Fatalf("none auth warning = %q, want reads exposed and changes held to the launch token", got)
}
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, "127.0.0.1:8787"); got != "" {
t.Fatalf("loopback warning = %q, want empty", got)
@@ -44,7 +44,7 @@ func walletServer(t *testing.T, balanceURL string) *httptest.Server {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active, Balance: billing.NewCache(nil, balanceURL, "")})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
+9 -9
View File
@@ -34,7 +34,7 @@ func readJSON[T any](t *testing.T, base, path string) T {
// hand itself the boundary it is supposed to be held by.
func TestBoundaryWritesRefusedWithoutGrant(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for _, path := range []string{"/permissions", "/sandbox", "/browser-tools"} {
@@ -49,7 +49,7 @@ func TestBoundaryWritesRefusedWithoutGrant(t *testing.T) {
func TestSavePermissionsPersistsAllThreeLists(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := `{"mode":"deny","deny":["bash(git push:*)"],"ask":["bash(rm:*)"],"allow":["bash(go test:*)"]}`
@@ -88,7 +88,7 @@ func TestSavePermissionsPersistsAllThreeLists(t *testing.T) {
func TestSavePermissionsRejectsUnparsableRule(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/permissions", `{"mode":"ask","deny":["(no tool name)"]}`)
@@ -110,7 +110,7 @@ func TestSavePermissionsRejectsUnparsableRule(t *testing.T) {
func TestSavePermissionsReplacesRatherThanAppends(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
first := postProvider(t, srv.URL, "/permissions", `{"mode":"ask","deny":["bash(rm:*)","bash(git push:*)"]}`)
@@ -130,7 +130,7 @@ func TestSavePermissionsReplacesRatherThanAppends(t *testing.T) {
func TestSandboxSettingsReportWhatTheConfinerWillUse(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/sandbox", `{"bash":"off","network":false,"allowWrite":["/tmp/scratch"," "]}`)
@@ -171,7 +171,7 @@ func TestSandboxSettingsReportWhatTheConfinerWillUse(t *testing.T) {
func TestSandboxSettingsReportTheModeThatWillRun(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
want := "enforce"
@@ -198,7 +198,7 @@ func TestSaveSandboxRefusesEnforceWithoutABackendByCode(t *testing.T) {
}
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/sandbox", `{"bash":"enforce"}`)
@@ -226,7 +226,7 @@ func TestSaveSandboxRefusesEnforceWithoutABackendByCode(t *testing.T) {
func TestSaveSandboxRejectsUnknownBashMode(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/sandbox", `{"bash":"maybe"}`)
@@ -241,7 +241,7 @@ func TestSaveSandboxRejectsUnknownBashMode(t *testing.T) {
func TestSaveBrowserToolsPersists(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
if got := readJSON[control.BrowserToolsSettings](t, srv.URL, "/browser-tools"); !got.Enabled {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func TestBrowserTabsIsAnEmptyListWithoutABrowser(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/browser/tabs")
if err != nil {
@@ -66,7 +66,7 @@ func TestAMalformedActivationStoreIsNotABadRequest(t *testing.T) {
WorkspaceRoot: testenv.TempDir(t),
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
got := postSwitch(t, srv.URL, `{"name":"deploy","enabled":false,"scope":"project"}`)
@@ -84,7 +84,7 @@ func TestAnUnknownSkillIsTheThingMissing(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
missing := postSwitch(t, srv.URL, `{"name":"no-such-skill","enabled":false}`)
@@ -146,7 +146,7 @@ func TestTheStatusAHostRecordedReachesTheRow(t *testing.T) {
ctrl := control.New(control.Options{Host: host})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/mcp")
+11 -11
View File
@@ -18,7 +18,7 @@ import (
func fetchSlash(t *testing.T, ctrl *control.Controller) []slashEntry {
t.Helper()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/slash")
@@ -77,7 +77,7 @@ func TestSlashCommandShadowsSameNamedSkill(t *testing.T) {
func TestMcpWithoutHostReturnsEmptyList(t *testing.T) {
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/mcp")
@@ -110,7 +110,7 @@ func TestSkillsListsWhatSlashCannotShow(t *testing.T) {
},
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
var got struct {
@@ -155,7 +155,7 @@ func TestSkillEnabledPersists(t *testing.T) {
Skills: []skill.Skill{{Name: "audit", Scope: skill.ScopeBuiltin}},
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/skills/enabled", "application/json",
@@ -186,7 +186,7 @@ func TestSkillSwitchIsScopedToItsProject(t *testing.T) {
theirs := control.New(control.Options{Skills: skills, WorkspaceRoot: there})
defer theirs.Close()
srv := httptest.NewServer(New(mine, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(mine, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/skills/enabled", "application/json",
@@ -209,7 +209,7 @@ func TestSkillSwitchIsScopedToItsProject(t *testing.T) {
func TestMcpAdminRejectsUnknownServer(t *testing.T) {
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
for _, tc := range []struct {
@@ -239,7 +239,7 @@ func TestMcpAdminRejectsUnknownServer(t *testing.T) {
func TestMcpParsePreviewsWithoutInstalling(t *testing.T) {
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/mcp/parse", "application/json",
@@ -285,7 +285,7 @@ func TestMcpParsePreviewsWithoutInstalling(t *testing.T) {
func TestMcpInstallRejectsDuplicateName(t *testing.T) {
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
// A command that cannot connect still exercises the pre-flight checks, and
@@ -343,7 +343,7 @@ func TestCapabilitySwitchReachesAnotherProject(t *testing.T) {
WorkspaceRoot: here,
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/skills/enabled", "application/json",
@@ -379,7 +379,7 @@ func TestCapabilitySwitchRefusesAnUnknownProject(t *testing.T) {
WorkspaceRoot: testenv.TempDir(t),
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/skills/enabled", "application/json",
@@ -494,7 +494,7 @@ func TestMcpLoadWritesTheUserConfig(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
for _, tc := range []struct {
+1 -1
View File
@@ -21,7 +21,7 @@ func TestChangesReportsTheTreeNotTheTranscript(t *testing.T) {
dir := testenv.TempDir(t)
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
var body struct {
+3 -3
View File
@@ -20,7 +20,7 @@ import (
func completeAt(t *testing.T, ctrl *control.Controller, line string, cursor int) control.Completion {
t.Helper()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
q := url.Values{"line": {line}, "cursor": {strconv.Itoa(cursor)}}
@@ -113,7 +113,7 @@ func TestCompleteRefusesPathsOutsideWorkspace(t *testing.T) {
func TestSubmitAcceptsAVerbThatStartsNoTurn(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: testenv.TempDir(t), WorkspaceRoot: testenv.TempDir(t)})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
for _, verb := range []string{"/compact", "/context", "/clear"} {
@@ -155,7 +155,7 @@ func TestHistoryShowsWhatWasTypedNotWhatWasSent(t *testing.T) {
func TestCompleteVerbsFollowTheAskingWindow(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: testenv.TempDir(t), WorkspaceRoot: testenv.TempDir(t)})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
hints := func(lang string) map[string]string {
+4 -4
View File
@@ -36,7 +36,7 @@ func TestASaveIntoAnUnreadableConfigRefusesWithOneCode(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
breakUserConfig(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for _, path := range []string{"/sandbox", "/permissions"} {
@@ -64,7 +64,7 @@ func TestTheProblemIsReadableBeforeAnythingIsSaved(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
breakUserConfig(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
problem := readJSON[*control.ConfigProblem](t, srv.URL, "/config/problem")
@@ -80,7 +80,7 @@ func TestRepairingTheFileLetsSettingsSaveAgain(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
breakUserConfig(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/config/repair", "")
@@ -107,7 +107,7 @@ func TestRepairingTheFileLetsSettingsSaveAgain(t *testing.T) {
func TestTheOffendingLineIsNotHandedToAnUngrantedClient(t *testing.T) {
s := newProviderEditServer(t)
breakUserConfig(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp, err := http.Get(srv.URL + "/config/problem")
@@ -149,7 +149,7 @@ func TestSetContextWindowMidTurnSaysItAppliesAfterTheTurn(t *testing.T) {
// Declaring a window writes the config file and rebuilds the runtime, so it
// rides the same grant as every other route that edits a source.
func TestSetContextWindowRefusedWithoutGrant(t *testing.T) {
srv := httptest.NewServer(newProviderEditServer(t).Handler())
srv := httptest.NewServer(operatorHandler(newProviderEditServer(t)))
defer srv.Close()
resp := declareWindow(t, srv.URL, `{"window":200000}`)
@@ -33,7 +33,7 @@ func TestStatusProjectsThePendingApprovalTheEventAnnounced(t *testing.T) {
Policy: permission.New("ask", nil, nil, nil),
})
ctrl.EnableInteractiveApproval()
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
frames, cancel := bc.Subscribe()
+1 -1
View File
@@ -87,7 +87,7 @@ func newShareRig(t *testing.T) *shareRig {
h := NewHub(HubOptions{Page: page, Share: share, Grant: func(s *Server) { s.AllowProviderEdit() }})
hubRuntime(t, h, testenv.TempDir(t))
share.Attach(h.Handler())
window := httptest.NewServer(h.Handler())
window := httptest.NewServer(operatorHandler(h))
t.Cleanup(func() {
window.Close()
share.Close()
+4 -4
View File
@@ -30,7 +30,7 @@ func TestDropAnswersWithAReferenceToTheFileItself(t *testing.T) {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp := postJSON(t, srv.URL+"/drop", map[string][]string{"paths": {file}})
@@ -63,7 +63,7 @@ func TestDropReportsOnePathWithoutLosingTheRest(t *testing.T) {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp := postJSON(t, srv.URL+"/drop", map[string][]string{
@@ -91,7 +91,7 @@ func TestAttachmentStoresNamedBytesThatAreNotAnImage(t *testing.T) {
dir := testenv.TempDir(t)
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp := postJSON(t, srv.URL+"/attachments", map[string]string{
@@ -126,7 +126,7 @@ func TestDropSaysNotAnImageInsteadOfSayingNothing(t *testing.T) {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp := postJSON(t, srv.URL+"/drop", map[string][]string{"paths": {doc}})
@@ -39,7 +39,7 @@ api_key_env = "RELAY_API_KEY"
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, ModelRef: "relay/claude-sonnet-4-5"})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
res, err := http.Post(srv.URL+"/effort", "application/json", strings.NewReader(`{"effort":"high"}`))
+1 -1
View File
@@ -49,7 +49,7 @@ func (c *extensionCtl) SubmitExtensionForm(_ context.Context, pluginID, surfaceI
func extensionServer(t *testing.T, ctl *extensionCtl) *httptest.Server {
t.Helper()
srv := httptest.NewServer(New(ctl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
@@ -30,7 +30,7 @@ func postGoalLifecycle(t *testing.T, url string) goalLifecycleResult {
func TestServeGoalPauseAndResumeKeepTheObjective(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
if got := postGoalLifecycle(t, srv.URL+"/goal/resume"); got.Changed {
+105
View File
@@ -0,0 +1,105 @@
package serve
import (
"net"
"net/http"
"strings"
"sync"
)
// codeHostRejected refuses an auth-disabled request addressed to a name this
// listener was not bound to: a DNS-rebound page reaches loopback under its
// own name, and nothing else stops it reading what an open serve answers.
const codeHostRejected = "serve.host_rejected"
// hostAllowlist is which Host headers an auth-disabled serve answers. Shared by
// a hub and every server it adopts, like the auth gate that holds it.
type hostAllowlist struct {
mu sync.Mutex
names map[string]bool
}
// ListenOn records the address serve is bound to. A wildcard bind answers for
// this machine's interface addresses as IP literals, never for a name a
// resolver could point here.
func (h *hostAllowlist) ListenOn(addr string) {
host := hostOnlyName(addr)
ip := net.ParseIP(host)
h.mu.Lock()
defer h.mu.Unlock()
if ip == nil || !ip.IsUnspecified() {
h.addLocked(host)
return
}
for _, a := range interfaceAddrs() {
h.addLocked(a)
}
}
// interfaceAddrs lists this machine's interface IPs; tests replace it.
var interfaceAddrs = func() []string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return nil
}
out := make([]string, 0, len(addrs))
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok {
out = append(out, n.IP.String())
}
}
return out
}
// Allow adds a name serve answers besides its bound address, such as the host
// of --public-url a proxy forwards.
func (h *hostAllowlist) Allow(host string) {
h.mu.Lock()
defer h.mu.Unlock()
h.addLocked(hostOnlyName(host))
}
func (h *hostAllowlist) addLocked(host string) {
if host == "" {
return
}
if h.names == nil {
h.names = map[string]bool{}
}
h.names[host] = true
}
func (h *hostAllowlist) admits(hostport string) bool {
host := hostOnlyName(hostport)
if host == "" || isLoopbackHost(host) {
return true
}
h.mu.Lock()
defer h.mu.Unlock()
return h.names[host]
}
func hostOnlyName(hostport string) string {
host := strings.TrimSpace(hostport)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
return strings.ToLower(strings.Trim(host, "[]"))
}
// AllowHost lets an auth-disabled server answer requests addressed to host.
func (s *Server) AllowHost(host string) { s.auth.hosts.Allow(host) }
// hostGuard applies the allowlist while authentication is off. A request that
// carries the launch token, or passed a gate that pins Host itself (loopback,
// device, in-process), is not a rebound page and passes.
func (ag *authGate) hostGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, viaDeviceGate := DeviceOf(r.Context())
if ag.mode == authNone && !ag.hosts.admits(r.Host) && !viaDeviceGate && !provenOperator(r) && !ag.presentsLaunchToken(r) {
refuse(w, http.StatusMisdirectedRequest, codeHostRejected, "this serve does not answer for that host", nil)
return
}
next.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -505,7 +505,7 @@ func (h *Hub) Handler() http.Handler {
h.registerUpdateRoutes(hostMux)
h.registerAskRoutes(hostMux)
h.registerShareRoutes(hostMux)
return logMiddleware(h.auth.middleware(withPage(csrfGuard(hostOnly(hostMux, mux)), h.opts.Page)))
return logMiddleware(h.auth.middleware(h.auth.hostGuard(withPage(csrfGuard(h.auth.mutationGate(hostOnly(hostMux, mux))), h.opts.Page))))
}
func (h *Hub) listRuntimes(w http.ResponseWriter, _ *http.Request) {
+1 -1
View File
@@ -58,7 +58,7 @@ func TestHubPanesShareOneWalletRead(t *testing.T) {
h := NewHub(HubOptions{})
defer h.Shutdown()
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
// Three panes, the way switching between conversations opens them.
@@ -70,7 +70,7 @@ func TestDecisionIdentityCannotCrossRuntimeRoutes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, runtimePrefix+idB+"/answer",
strings.NewReader(`{"id":"`+cardA.ID+`","answers":[{"QuestionID":"q1","Selected":["A"]}]}`))
req.Header.Set("Content-Type", "application/json")
h.Handler().ServeHTTP(httptest.NewRecorder(), req)
operatorHandler(h).ServeHTTP(httptest.NewRecorder(), req)
if got := a.Decisions(); len(got) != 1 || got[0].ID != cardA.ID {
t.Fatalf("a request routed to B answered A's card: %+v", got)
@@ -69,7 +69,7 @@ func TestOpenPaneOnADeletedSessionSaysItIsGoneNotHeld(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
gone := filepath.Join(SessionDirFor(root), "deleted.jsonl")
@@ -91,7 +91,7 @@ func TestOpenPaneReadsASessionAnotherRuntimeHolds(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
held := filepath.Join(SessionDirFor(root), "held.jsonl")
+14 -14
View File
@@ -110,7 +110,7 @@ func TestRemotePaneStreamsEventsUnbuffered(t *testing.T) {
rk := fakeRemoteKernel(t)
h := NewHub(HubOptions{})
rt := remotePane(t, h, rk.Listener.Addr().String())
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, front.URL+rt.view().Base+"/events", nil)
@@ -166,7 +166,7 @@ func TestRemotePaneCarriesTheRemoteToken(t *testing.T) {
rk := fakeRemoteKernel(t)
h := NewHub(HubOptions{})
rt := remotePane(t, h, rk.Listener.Addr().String())
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, front.URL+rt.view().Base+"/status", nil)
@@ -200,7 +200,7 @@ func TestRemotePaneRefusesWithACodeWhenTheLinkIsDown(t *testing.T) {
h := NewHub(HubOptions{})
rt := remotePane(t, h, addr)
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Get(front.URL + rt.view().Base + "/status")
@@ -243,7 +243,7 @@ func TestAFarKernelThatRefusesSaysSoRatherThanLookingLikeADeadLink(t *testing.T)
Addr: far.Listener.Addr().String(), Token: "remote-secret",
}, func() {}, nil
}}})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Post(front.URL+"/remotes/open", "application/json",
@@ -293,7 +293,7 @@ func TestAFarKernelWithNoPaneHubReadsAsAVersionBehind(t *testing.T) {
Addr: far.Listener.Addr().String(), Token: "remote-secret",
}, func() {}, nil
}}})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Post(front.URL+"/remotes/open", "application/json",
@@ -395,7 +395,7 @@ func TestClosingARemotePaneReleasesItsHold(t *testing.T) {
func TestOpenRemoteIsRefusedWhereNoAttacherIsWired(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Post(front.URL+"/remotes/open", "application/json",
@@ -433,7 +433,7 @@ func TestOpenRemoteEndpointPublishesTheProxiedPane(t *testing.T) {
}, func() {}, nil
},
}})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Post(front.URL+"/remotes/open", "application/json",
@@ -470,7 +470,7 @@ func TestOpenRemoteGivesBackTheHoldWhenTheHubRefuses(t *testing.T) {
func() { released <- struct{}{} }, nil
},
}})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp, err := http.Post(front.URL+"/remotes/open", "application/json", strings.NewReader(`{"host":"gpu-box"}`))
@@ -513,7 +513,7 @@ host = "10.0.0.5"
h := NewHub(HubOptions{Remote: &stubAttacher{states: map[string]RemoteLinkState{
"gpu-box": {Status: "degraded", Err: "forward 8080 未挂上", Panes: 2},
}}})
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
var rows []RemoteHostView
@@ -552,7 +552,7 @@ func TestTwoRemotePanesDriveTwoRuntimesOnTheFarSide(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
near := NewHub(HubOptions{Remote: &stubAttacher{
@@ -565,7 +565,7 @@ func TestTwoRemotePanesDriveTwoRuntimesOnTheFarSide(t *testing.T) {
}, func() {}, nil
},
}})
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
// One workspace for both, which is the case that was broken: they share a
@@ -665,7 +665,7 @@ func TestClosingARemotePaneRetiresTheFarRuntime(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
near := NewHub(HubOptions{Remote: &stubAttacher{
@@ -676,7 +676,7 @@ func TestClosingARemotePaneRetiresTheFarRuntime(t *testing.T) {
}, func() {}, nil
},
}})
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
resp, err := http.Post(nearSide.URL+"/remotes/open", "application/json",
@@ -768,7 +768,7 @@ func TestRemotePaneCarriesTheResumePointToTheFarKernel(t *testing.T) {
upstream := resumeKernel(t, resumed)
h := NewHub(HubOptions{})
rt := remotePane(t, h, upstream.Listener.Addr().String())
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
// Two connections, because the point is per-request: a proxy that cached
+1 -1
View File
@@ -53,7 +53,7 @@ func TestHubResolverPaneNeverFallsBackToThisMachinesConfig(t *testing.T) {
h := NewHub(HubOptions{ProviderResolver: homeResolver()})
defer h.Shutdown()
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
rt, err := h.Open(context.Background(), OpenRequest{Root: testenv.TempDir(t)})
+5
View File
@@ -18,9 +18,14 @@ func (h *Hub) RunGraceful(ctx context.Context, addr string) error {
// RunGracefulListener is RunGraceful over a caller-supplied listener, for hosts
// that need the bound address before serving (--addr :0 with --port-file).
func (h *Hub) RunGracefulListener(ctx context.Context, ln net.Listener) error {
h.auth.hosts.ListenOn(ln.Addr().String())
return runGracefulListener(ctx, ln, h.Handler())
}
// AllowHost lets an auth-disabled hub answer requests addressed to host, such
// as the name a proxy in front of it forwards.
func (h *Hub) AllowHost(host string) { h.auth.hosts.Allow(host) }
// StartRecoveryGC sweeps redundant recovery branches for every open pane, and
// for each one opened later.
func (h *Hub) StartRecoveryGC(ctx context.Context) {
+6 -6
View File
@@ -58,7 +58,7 @@ func TestHubRoutesEachRuntimeToItsOwnWorkspace(t *testing.T) {
h := NewHub(HubOptions{})
a := hubRuntime(t, h, first)
b := hubRuntime(t, h, second)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
for _, want := range []struct {
@@ -120,7 +120,7 @@ func TestHubTreeListsWorkspaceSessionsAndMarksOpenOnes(t *testing.T) {
}
rt.Server.Controller().SetSessionPath(open)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
tree := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(tree) != 1 || tree[0].Root != root {
@@ -147,7 +147,7 @@ func TestHubCloseRetiresTheRuntime(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
rt := hubRuntime(t, h, testenv.TempDir(t))
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
if err := h.Close(rt.ID); err != nil {
@@ -183,7 +183,7 @@ func TestHubServesTheFirstRuntimeUnprefixed(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
got := hubGet[struct {
@@ -277,7 +277,7 @@ func TestHubTreeFoldsRecoveryCopiesIntoTheirConversation(t *testing.T) {
parent = info.Path
}
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
tree := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(tree) != 1 {
@@ -352,7 +352,7 @@ func TestUnclaimedServerRecordsAsServe(t *testing.T) {
func TestHubServesThePageWithNoPaneOpen(t *testing.T) {
h := NewHub(HubOptions{Page: fstest.MapFS{"index.html": {Data: []byte("<!doctype html><title>studio</title>")}}})
defer h.Shutdown()
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
@@ -47,7 +47,7 @@ func TestExportSessionReadsOnlyAKnownWorkspaceTranscript(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
path := filepath.Join(SessionDirFor(root), "export-me.jsonl")
@@ -105,7 +105,7 @@ func TestRemoveSessionRefusesALeasedTranscript(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
path := filepath.Join(SessionDirFor(root), "held.jsonl")
@@ -151,7 +151,7 @@ func TestRemoveSessionSucceedsOnceTheLeaseIsReleased(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
path := filepath.Join(SessionDirFor(root), "free.jsonl")
@@ -184,7 +184,7 @@ func TestRemoveSessionNamesTheProcessHoldingIt(t *testing.T) {
root := testenv.TempDir(t)
h := NewHub(HubOptions{})
hubRuntime(t, h, root)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
path := filepath.Join(SessionDirFor(root), "elsewhere.jsonl")
@@ -249,7 +249,7 @@ func TestRemoveSessionClosesAnIdlePaneShowingIt(t *testing.T) {
if _, err := h.Open(context.Background(), OpenRequest{Root: root, SessionPath: path}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
resp := postRemoveSession(t, srv, path)
@@ -22,7 +22,7 @@ func TestTreeListsAHeldSessionBeforeItIsWritten(t *testing.T) {
path := sessionstore.NewSessionPath(SessionDirFor(root), "test")
rt.Server.Controller().SetSessionPath(path)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
tree := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(tree) != 1 {
+1 -1
View File
@@ -89,7 +89,7 @@ func TestInProcessClientStreamsEventsAndRunsTheUsersShell(t *testing.T) {
// mark a request, and nothing a network client sends reaches that mark.
func TestShellOverTheNetworkStaysRefused(t *testing.T) {
h, _, _ := adoptedPane(t, control.ToolApprovalAsk)
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
resp := clientPostJSON(t, srv.Client(), srv.URL+"/rt/r1/submit", `{"input":"!echo over-the-network"}`)
defer resp.Body.Close()
+69
View File
@@ -0,0 +1,69 @@
package serve
import (
"context"
"crypto/subtle"
"net/http"
"strings"
)
// codeLaunchTokenRequired refuses a state-changing request that carries no
// launch token while serve authentication is turned off.
const codeLaunchTokenRequired = "auth.launch_token_required"
type hostGatedKey struct{}
// withHostGated marks a request that already proved the host's launch
// credential at a gate in front of this package's own.
func withHostGated(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), hostGatedKey{}, true))
}
// provenOperator reports a request some boundary already authenticated: the
// in-process transport, the loopback gate, or a paired device.
func provenOperator(r *http.Request) bool {
if fromInProcess(r) {
return true
}
if v, _ := r.Context().Value(hostGatedKey{}).(bool); v {
return true
}
id, ok := DeviceOf(r.Context())
return ok && id != ""
}
// presentsLaunchToken reports whether r carries this launch's token, as the
// cookie /auth/token issues or as an Authorization bearer.
func (ag *authGate) presentsLaunchToken(r *http.Request) bool {
if ag.token == "" {
return false
}
if c, err := r.Cookie(cookieToken); err == nil && launchTokenEqual(c.Value, ag.token) {
return true
}
scheme, value, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
return ok && strings.EqualFold(scheme, "Bearer") && launchTokenEqual(value, ag.token)
}
func launchTokenEqual(got, want string) bool {
got = strings.TrimSpace(got)
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
// mutationGate holds an auth-disabled serve's mutations to the launch token.
// Approvals require the launch token even then: reaching the listener does not
// make a caller the operator, and any mutation can widen what the agent may do,
// so the method decides rather than a list of routes.
func (ag *authGate) mutationGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ag.mode == authNone && !safeMethod(r.Method) && !provenOperator(r) && !ag.presentsLaunchToken(r) {
refuse(w, http.StatusForbidden, codeLaunchTokenRequired, "approvals require the launch token", nil)
return
}
next.ServeHTTP(w, r)
})
}
func safeMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
}
@@ -0,0 +1,139 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestHubAuthDisabledGateHoldsEveryMutationSpelling(t *testing.T) {
hub := NewHub(HubOptions{Serve: config.ServeConfig{AuthMode: "none"}})
defer hub.Shutdown()
bc := NewBroadcaster()
rt, err := hub.Adopt(New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{}), bc)
if err != nil {
t.Fatal(err)
}
h := hub.Handler()
tok := hub.auth.Token()
cases := []struct {
name, method, target string
hdr map[string]string
wantCode string
}{
{"runtime-prefixed approve", http.MethodPost, runtimePrefix + rt.ID + "/approve", nil, codeLaunchTokenRequired},
{"PUT", http.MethodPut, "/approve", nil, codeLaunchTokenRequired},
{"PATCH", http.MethodPatch, "/inbox/items/x", nil, codeLaunchTokenRequired},
{"DELETE", http.MethodDelete, "/sessions/x", nil, codeLaunchTokenRequired},
{"PROPFIND", "PROPFIND", "/approve", nil, codeLaunchTokenRequired},
{"override header", http.MethodPost, "/approve", map[string]string{"X-HTTP-Method-Override": "GET"}, codeLaunchTokenRequired},
{"query token ignored", http.MethodPost, "/approve?token=" + tok, nil, codeLaunchTokenRequired},
{"host route runtimes", http.MethodPost, "/runtimes", nil, codeLaunchTokenRequired},
{"hub bearer", http.MethodPost, "/approve", map[string]string{"Authorization": "Bearer " + tok}, ""},
}
for _, c := range cases {
req := httptest.NewRequest(c.method, c.target, strings.NewReader(`{"allow":true}`))
req.Host = "127.0.0.1:8787"
req.Header.Set("Content-Type", "application/json")
for k, v := range c.hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var reason Reason
_ = json.NewDecoder(rec.Body).Decode(&reason)
if c.wantCode != "" && (rec.Code != http.StatusForbidden || reason.Code != c.wantCode) {
t.Errorf("%s: %s %s = %d %q, want 403 %q", c.name, c.method, c.target, rec.Code, reason.Code, c.wantCode)
}
if c.wantCode == "" && reason.Code == codeLaunchTokenRequired {
t.Errorf("%s: refused with launch token", c.name)
}
}
}
// A request reaching the hub through NewDeviceGate's public-path branch has
// device reach with an empty id; it must not count as a proven operator.
func TestUnpairedDeviceReachIsNotOperator(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/approve", nil)
req = req.WithContext(withDeviceReach(req.Context(), "", 0))
if provenOperator(req) {
t.Fatal("unpaired device reach counted as operator")
}
}
// With auth none a read addressed to a foreign name is refused, so a
// DNS-rebound page cannot read through a loopback listener.
func TestHubAuthDisabledRefusesForeignHost(t *testing.T) {
hub := NewHub(HubOptions{Serve: config.ServeConfig{AuthMode: "none"}})
defer hub.Shutdown()
bc := NewBroadcaster()
if _, err := hub.Adopt(New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{}), bc); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/status", nil)
req.Host = "rebind.attacker.example:8787"
rec := httptest.NewRecorder()
hub.Handler().ServeHTTP(rec, req)
var reason Reason
_ = json.NewDecoder(rec.Body).Decode(&reason)
if rec.Code != http.StatusMisdirectedRequest || reason.Code != codeHostRejected {
t.Fatalf("GET /status with Host=%s = %d %q, want 421 %q", req.Host, rec.Code, reason.Code, codeHostRejected)
}
req = httptest.NewRequest(http.MethodGet, "/status", nil)
req.Host = "127.0.0.1:8787"
rec = httptest.NewRecorder()
hub.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /status on loopback = %d, want 200", rec.Code)
}
hub.AllowHost("agent.example.com")
req = httptest.NewRequest(http.MethodGet, "/status", nil)
req.Host = "agent.example.com"
rec = httptest.NewRecorder()
hub.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /status on an allowed public host = %d, want 200", rec.Code)
}
}
// The device gate pins Host to its own origin, so a stranger loading the page
// over the LAN is not a rebound request even though no pairing exists yet.
func TestHubAuthDisabledServesThePageThroughTheDeviceGate(t *testing.T) {
hub := NewHub(HubOptions{Page: fstest.MapFS{"index.html": {Data: []byte("<title>studio</title>")}}})
defer hub.Shutdown()
gate := NewDeviceGate(hub.Handler(), DeviceGateOptions{Registry: NewDeviceRegistry(), Origin: "http://192.168.1.5:9000", Page: hub.opts.Page})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "192.168.1.5:9000"
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unpaired device page load = %d %s, want 200", rec.Code, rec.Body)
}
}
// A wildcard bind answers for this machine's interface addresses, not for any
// name: a rebound page on 0.0.0.0 is still a foreign Host.
func TestAuthDisabledWildcardBindAdmitsOnlyInterfaceAddresses(t *testing.T) {
previous := interfaceAddrs
interfaceAddrs = func() []string { return []string{"192.168.1.5", "fe80::1"} }
t.Cleanup(func() { interfaceAddrs = previous })
var hosts hostAllowlist
hosts.ListenOn("0.0.0.0:8787")
for host, want := range map[string]bool{
"192.168.1.5:8787": true,
"[fe80::1]:8787": true,
"localhost:8787": true,
"rebind.attacker.example": false,
"10.0.0.9:8787": false,
} {
if got := hosts.admits(host); got != want {
t.Errorf("wildcard bind admits %q = %v, want %v", host, got, want)
}
}
}
@@ -0,0 +1,167 @@
package serve
import (
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
// operatorHandler stands in for the operator's frontend, which holds the
// launch token that mutations require.
func operatorHandler(s interface {
Handler() http.Handler
AuthToken() string
}) http.Handler {
h := s.Handler()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Authorization", "Bearer "+s.AuthToken())
h.ServeHTTP(w, r)
})
}
func postLaunchJSON(t *testing.T, url, body string, header http.Header) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
maps.Copy(req.Header, header)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var reason Reason
_ = json.NewDecoder(resp.Body).Decode(&reason)
return resp.StatusCode, reason.Code
}
func TestAuthDisabledRefusesMutationsWithoutLaunchToken(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{AuthMode: "none"}).Handler())
defer srv.Close()
for _, route := range []string{"/approve", "/plan-decision", "/answer", "/bypass", "/tool-approval-mode", "/auto-approve-tools", "/permissions", "/sandbox", "/hooks", "/submit"} {
status, code := postLaunchJSON(t, srv.URL+route, `{"id":"1","allow":true,"session":true}`, nil)
if status != http.StatusForbidden || code != codeLaunchTokenRequired {
t.Errorf("POST %s without launch token = %d %q, want 403 %q", route, status, code, codeLaunchTokenRequired)
}
}
resp, err := http.Get(srv.URL + "/status")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /status with auth disabled = %d, want 200: reads stay open", resp.StatusCode)
}
}
func TestAuthDisabledAcceptsMutationWithLaunchToken(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
s := New(ctrl, bc, config.ServeConfig{AuthMode: "none"})
srv := httptest.NewServer(s.Handler())
defer srv.Close()
if s.AuthToken() == "" {
t.Fatal("auth-disabled serve has no launch token")
}
if status, _ := postLaunchJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer " + s.AuthToken()}}); status != http.StatusBadRequest {
t.Fatalf("POST /approve with bearer launch token = %d, want the handler's 400 for a missing id", status)
}
if status, code := postLaunchJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer wrong"}}); status != http.StatusForbidden || code != codeLaunchTokenRequired {
t.Fatalf("POST /approve with a wrong token = %d %q, want 403", status, code)
}
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/auth/token", strings.NewReader(`{"token":"`+s.AuthToken()+`"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == cookieToken {
cookie = c
}
}
if resp.StatusCode != http.StatusNoContent || cookie == nil {
t.Fatalf("launch token bootstrap = %d cookie=%v, want 204 with %s", resp.StatusCode, cookie, cookieToken)
}
if status, _ := postLaunchJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Cookie": {cookie.Name + "=" + cookie.Value}}); status != http.StatusBadRequest {
t.Fatalf("POST /approve with launch token cookie = %d, want the handler's 400", status)
}
}
func TestAuthDisabledHonoursConfiguredToken(t *testing.T) {
s := New(control.New(control.Options{Sink: NewBroadcaster()}), nil, config.ServeConfig{AuthMode: "none", Token: "proxy-held"})
if s.AuthToken() != "proxy-held" {
t.Fatalf("launch token = %q, want the configured token a fronting proxy injects", s.AuthToken())
}
}
func TestTokenModeAcceptsBearerLaunchToken(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
s := New(ctrl, bc, config.ServeConfig{AuthMode: "token"})
srv := httptest.NewServer(s.Handler())
defer srv.Close()
if status, _ := postLaunchJSON(t, srv.URL+"/approve", `{"id":"1","allow":true}`, nil); status != http.StatusUnauthorized {
t.Fatalf("unauthenticated POST /approve in token mode = %d, want 401", status)
}
if status, _ := postLaunchJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer " + s.AuthToken()}}); status != http.StatusBadRequest {
t.Fatalf("POST /approve with bearer token = %d, want the handler's 400", status)
}
}
func TestHubWithAuthDisabledAdmitsOnlyGatedOrInProcessMutations(t *testing.T) {
hub := NewHub(HubOptions{})
defer hub.Shutdown()
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
if _, err := hub.Adopt(New(ctrl, bc, config.ServeConfig{}), bc); err != nil {
t.Fatal(err)
}
direct := httptest.NewServer(hub.Handler())
defer direct.Close()
if status, code := postLaunchJSON(t, direct.URL+"/approve", `{"id":"1","allow":true}`, nil); status != http.StatusForbidden || code != codeLaunchTokenRequired {
t.Fatalf("hub POST /approve off a bare socket = %d %q, want 403 %q", status, code, codeLaunchTokenRequired)
}
resp, err := hub.InProcessClient().Post("http://kernel/approve", "application/json", strings.NewReader(`{"allow":true}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("in-process POST /approve = %d, want the handler's 400", resp.StatusCode)
}
ln, err := ListenLoopback()
if err != nil {
t.Fatal(err)
}
origin := LoopbackOrigin(ln)
gated := httptest.NewUnstartedServer(NewLoopbackGate(hub.Handler(), LoopbackGateOptions{Token: "launch-credential", Origin: origin}))
_ = gated.Listener.Close()
gated.Listener = ln
gated.Start()
defer gated.Close()
status, _ := postLaunchJSON(t, origin+"/approve", `{"allow":true}`, http.Header{
"Origin": {origin},
"Cookie": {TokenCookie + "=launch-credential"},
})
if status != http.StatusBadRequest {
t.Fatalf("loopback-gated POST /approve = %d, want the handler's 400", status)
}
}
+2
View File
@@ -15,6 +15,7 @@ import (
// "ask" decisions surface as approval_request events answered via POST /approve.
func (s *Server) Run(addr string) error {
s.ctl().EnableInteractiveApproval()
s.auth.hosts.ListenOn(addr)
return http.ListenAndServe(addr, s.Handler())
}
@@ -34,6 +35,7 @@ func (s *Server) RunGraceful(ctx context.Context, addr string) error {
// listen first, record ln.Addr(), then hand the listener here.
func (s *Server) RunGracefulListener(ctx context.Context, ln net.Listener) error {
s.ctl().EnableInteractiveApproval()
s.auth.hosts.ListenOn(ln.Addr().String())
return runGracefulListener(ctx, ln, s.Handler())
}
+1 -1
View File
@@ -78,7 +78,7 @@ func NewLoopbackGate(next http.Handler, opts LoopbackGateOptions) http.Handler {
// w passes through undecorated: /events asserts http.Flusher on it, and
// a wrapper that did not forward Flush would turn every stream into a
// response the page only sees once the turn is over.
next.ServeHTTP(w, r)
next.ServeHTTP(w, withHostGated(r))
})
}
+3 -3
View File
@@ -85,7 +85,7 @@ func offeredModelRefs(t *testing.T, base string) []string {
func TestEveryModelThePickerOffersIsAssignableToARole(t *testing.T) {
var offered []string
func() {
srv := httptest.NewServer(newRoleServerWithExtensionModel(t).Handler())
srv := httptest.NewServer(operatorHandler(newRoleServerWithExtensionModel(t)))
defer srv.Close()
offered = offeredModelRefs(t, srv.URL)
}()
@@ -95,7 +95,7 @@ func TestEveryModelThePickerOffersIsAssignableToARole(t *testing.T) {
// earlier assignment left behind.
for _, ref := range offered {
t.Run(ref, func(t *testing.T) {
srv := httptest.NewServer(newRoleServerWithExtensionModel(t).Handler())
srv := httptest.NewServer(operatorHandler(newRoleServerWithExtensionModel(t)))
defer srv.Close()
payload, err := json.Marshal(map[string]string{"role": "planner", "ref": ref})
if err != nil {
@@ -118,7 +118,7 @@ func TestEveryModelThePickerOffersIsAssignableToARole(t *testing.T) {
// catalogs, so it must not have widened into accepting anything with slashes.
func TestRoleStillRefusesARefNoCatalogCarries(t *testing.T) {
s := newRoleServerWithExtensionModel(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for _, ref := range []string{"plugin/ghost/cloud/nothing", "nosuch/model", "plugin/two"} {
+1 -1
View File
@@ -59,7 +59,7 @@ func modelsByRef(t *testing.T, cfgBody string) map[string]modelEntry {
ctrl := control.New(control.Options{
Sink: bc, Label: "text-only", ModelRef: "mixed/text-only", SessionDir: testenv.TempDir(t),
})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/models")
+1 -1
View File
@@ -42,7 +42,7 @@ func pluginHome(t *testing.T) (string, *pluginCtl, string) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
ctl := &pluginCtl{root: testenv.TempDir(t)}
srv := httptest.NewServer(New(ctl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
return home, ctl, srv.URL
}
@@ -32,7 +32,7 @@ func refineServer(t *testing.T, refiner *promptrefine.Refiner) *httptest.Server
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, PromptRefiner: refiner})
t.Cleanup(func() { ctrl.Close() })
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
+12 -12
View File
@@ -20,7 +20,7 @@ import (
func TestCheckProviderReportsWhatTheEndpointSaid(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/check", `{"name":"existing"}`)
@@ -47,7 +47,7 @@ func TestCheckProviderReportsWhatTheEndpointSaid(t *testing.T) {
func TestCheckProviderRefusesAnUnknownName(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/check", `{"name":"nobody"}`)
@@ -61,7 +61,7 @@ func TestCheckProviderRefusesAnUnknownName(t *testing.T) {
// it waits on the same grant every other provider edit does.
func TestCheckProviderWaitsOnTheGrant(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/check", `{"name":"existing"}`)
@@ -104,7 +104,7 @@ func TestCheckProviderModelAcceptsAnUnlistedExactIDWithoutSavingIt(t *testing.T)
t.Fatal(err)
}
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/check/model", `{"name":"existing","model":"Hidden-Preview/Exact-ID"}`)
@@ -141,7 +141,7 @@ func TestCheckProviderModelUsesUnsavedEndpointAndKeyOnlyForTheProbe(t *testing.T
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"private-model","baseUrl":%q,"apiKey":"one-time-key","kind":"openai"}`, upstream.URL+"/v1")
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -191,7 +191,7 @@ func TestCheckProviderModelUsesSystemOneForTypeSafe(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"jev-latest","baseUrl":%q,"apiKey":"one-time-key","kind":"typesafe"}`, upstream.URL)
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -230,7 +230,7 @@ func TestCheckProviderModelUsesSavedDecisionKindAndEnvironmentKey(t *testing.T)
t.Fatal(err)
}
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/check/model", `{"name":"existing","model":"jev-latest"}`)
defer resp.Body.Close()
@@ -257,7 +257,7 @@ func TestCheckProviderModelRejectsMalformedTypeSafeSuccess(t *testing.T) {
defer upstream.Close()
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
request := fmt.Sprintf(`{"name":"not-saved","model":"jev-latest","baseUrl":%q,"apiKey":"one-time-key","kind":"typesafe"}`, upstream.URL)
resp := postProvider(t, srv.URL, "/providers/check/model", request)
@@ -300,7 +300,7 @@ func TestCheckProviderModelClassifiesTypeSafeHTTPFailure(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"jev-latest","baseUrl":%q,"apiKey":"one-time-key","kind":"typesafe"}`, upstream.URL)
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -344,7 +344,7 @@ func TestCheckProviderModelReportsAnEndpointThatRefusesTools(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"m","baseUrl":%q,"apiKey":"k","kind":"openai"}`, upstream.URL+"/v1")
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -375,7 +375,7 @@ func TestCheckProviderModelKeepsRejectionWhenRemovingToolsDoesNotHelp(t *testing
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"m","baseUrl":%q,"apiKey":"k","kind":"openai"}`, upstream.URL+"/v1")
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -405,7 +405,7 @@ func TestCheckProviderModelRateLimitIsOneRedactedAttempt(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := fmt.Sprintf(`{"name":"not-saved","model":"private-model","baseUrl":%q,"apiKey":"one-time-key"}`, upstream.URL+"/v1")
resp := postProvider(t, srv.URL, "/providers/check/model", body)
@@ -55,7 +55,7 @@ func newContinuationServer(t *testing.T) *httptest.Server {
})
s := New(ctrl, bc, config.ServeConfig{})
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
return srv
}
@@ -68,7 +68,7 @@ func newRichProviderServerAs(t *testing.T, wrap func(control.SessionAPI) control
})
s := New(wrap(ctrl), bc, config.ServeConfig{})
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
return srv
}
@@ -235,7 +235,7 @@ api_key_env = "DEEPSEEK_API_KEY"
func TestEditProviderKeepsCompatibilityFields(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
body := `{"name":"existing","models":["model-a"],"default":"model-a","contextWindow":128000,` +
@@ -270,7 +270,7 @@ func TestEditProviderKeepsCompatibilityFields(t *testing.T) {
func TestEditProviderLeavesCompatibilityFieldsAloneWhenUnsent(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
first := postProvider(t, srv.URL, "/providers/edit",
@@ -294,7 +294,7 @@ func TestEditProviderLeavesCompatibilityFieldsAloneWhenUnsent(t *testing.T) {
func TestEditProviderRejectsNullInExtraBody(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/edit",
@@ -28,7 +28,7 @@ func savedProvider(t *testing.T, name string) *config.ProviderEntry {
func TestAddingASecondDoorReusesTheHostsKey(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
first := postProvider(t, srv.URL, "/providers", `{
@@ -64,7 +64,7 @@ func TestAddingASecondDoorReusesTheHostsKey(t *testing.T) {
func TestAddingASecondAccountAtOneHostKeepsItsOwnKey(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
postProvider(t, srv.URL, "/providers", `{
@@ -89,7 +89,7 @@ func TestAddingASecondAccountAtOneHostKeepsItsOwnKey(t *testing.T) {
func TestFirstSourceAtAHostGetsItsOwnKeySlot(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
postProvider(t, srv.URL, "/providers", `{
@@ -111,7 +111,7 @@ func TestANewSourceNeverWritesAnotherProvidersKeySlot(t *testing.T) {
if _, err := config.SetCredential("EXISTING_API_KEY", "sk-existing"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -140,7 +140,7 @@ func TestAKeylessSourceElsewhereDoesNotBorrowAFoldedSlot(t *testing.T) {
if _, err := config.SetCredential("EXISTING_API_KEY", "sk-existing"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -173,7 +173,7 @@ func TestAddingUnderAnExistingNameIsRefused(t *testing.T) {
if _, err := config.SetCredential("EXISTING_API_KEY", "sk-existing"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -194,7 +194,7 @@ func TestAddingUnderAnExistingNameIsRefused(t *testing.T) {
func TestAKeylessSourceDoesNotInheritARemovedProvidersKey(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
postProvider(t, srv.URL, "/providers", `{
@@ -220,7 +220,7 @@ func TestAKeylessSourceDoesNotInheritARemovedProvidersKey(t *testing.T) {
func TestReplacingAnEntryKeepsItsSlotOnlyAtItsHost(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -42,7 +42,7 @@ func TestProviderSetupStoresRemoteCredentialAndRebuildsController(t *testing.T)
}), nil
}
httpServer := httptest.NewServer(s.Handler())
httpServer := httptest.NewServer(operatorHandler(s))
defer httpServer.Close()
// The page onboards; this kernel only has to say a key is owed, and it must
@@ -144,7 +144,7 @@ func TestProviderSetupActivationFailureKeepsCredentialAndHidesDetails(t *testing
SessionDir: testenv.TempDir(t),
}), nil
}
httpServer := httptest.NewServer(s.Handler())
httpServer := httptest.NewServer(operatorHandler(s))
defer httpServer.Close()
resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"`+secret+`"}`)
@@ -197,7 +197,7 @@ func TestProviderSetupActivationRetryReturnsToMissingWhenCredentialWasRemoved(t
built++
return nil, errors.New("transient activation failure")
}
httpServer := httptest.NewServer(s.Handler())
httpServer := httptest.NewServer(operatorHandler(s))
defer httpServer.Close()
resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"`+secret+`"}`)
@@ -237,7 +237,7 @@ func TestProviderSetupRejectsCredentialSavedByAnotherProcess(t *testing.T) {
return nil, nil
}
httpServer := httptest.NewServer(s.Handler())
httpServer := httptest.NewServer(operatorHandler(s))
defer httpServer.Close()
resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"stale-browser-secret"}`)
resp.Body.Close()
@@ -286,7 +286,7 @@ func TestProviderSetupIsLoopbackOnlyAndAuthenticated(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/provider-setup", nil)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
operatorHandler(s).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled setup endpoint = %d, want 404", rec.Code)
}
@@ -309,7 +309,7 @@ func TestProviderSetupIsLoopbackOnlyAndAuthenticated(t *testing.T) {
func TestProviderSetupRejectsUnsafeOrAmbiguousRequests(t *testing.T) {
s, _ := newProviderSetupTestServer(t)
s.EnableProviderSetupForListener("127.0.0.1:8787")
httpServer := httptest.NewServer(s.Handler())
httpServer := httptest.NewServer(operatorHandler(s))
defer httpServer.Close()
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/provider-setup", strings.NewReader(`{"apiKey":"secret"}`))
+10 -10
View File
@@ -79,7 +79,7 @@ func postProvider(t *testing.T, base, path, body string) *http.Response {
// host says its only client is a local window.
func TestProviderEditIsRefusedUntilTheHostGrantsIt(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for _, path := range []string{"/providers", "/providers/probe", "/providers/check/model", "/providers/remove"} {
@@ -104,7 +104,7 @@ func TestProviderEditIsRefusedUntilTheHostGrantsIt(t *testing.T) {
func TestSaveProviderWritesConfigAndKeepsTheKeyOutOfIt(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -172,7 +172,7 @@ func TestSaveProviderWritesConfigAndKeepsTheKeyOutOfIt(t *testing.T) {
func TestSaveProviderNarrowsVisionToTheListedModels(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
@@ -210,7 +210,7 @@ func TestSaveProviderNarrowsVisionToTheListedModels(t *testing.T) {
func TestSaveProviderRejectsWhatItCannotStore(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
// The status the producer chose now reaches the wire, so this asserts what a
@@ -243,7 +243,7 @@ func TestSaveProviderRejectsWhatItCannotStore(t *testing.T) {
func TestRemoveProviderInUseGoesWhenIdle(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers/remove", `{"name":"existing"}`)
@@ -265,7 +265,7 @@ func TestRemoveProviderInUseGoesWhenIdle(t *testing.T) {
func TestRemoveProviderInUseMovesTheConversation(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
add := postProvider(t, srv.URL, "/providers", `{"name":"spare","kind":"openai","baseUrl":"https://x.invalid","apiKey":"sk-spare","models":["m"]}`)
@@ -284,7 +284,7 @@ func TestRemoveProviderInUseMovesTheConversation(t *testing.T) {
func TestRemoveProviderDropsAnUnusedOne(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
add := postProvider(t, srv.URL, "/providers", `{"name":"spare","kind":"openai","baseUrl":"https://x.invalid","models":["m"]}`)
@@ -308,7 +308,7 @@ func TestRemoveProviderDropsAnUnusedOne(t *testing.T) {
func TestProvidersListMarksTheOneInUse(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp, err := http.Get(srv.URL + "/providers")
@@ -361,7 +361,7 @@ func readAllString(resp *http.Response) (string, error) {
// build has to reach it without a frontend release.
func TestProviderProtocolsListsEveryWireASourceMayBeSavedAs(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp, err := http.Get(srv.URL + "/providers/protocols")
@@ -397,7 +397,7 @@ func TestProviderProtocolsListsEveryWireASourceMayBeSavedAs(t *testing.T) {
func TestSaveProviderAcceptsEveryCatalogedWire(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/providers", `{
+12 -12
View File
@@ -16,7 +16,7 @@ import (
func bookServer(t *testing.T, attacher RemoteAttacher) *httptest.Server {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
front := httptest.NewServer(NewHub(HubOptions{Remote: attacher}).Handler())
front := httptest.NewServer(operatorHandler(NewHub(HubOptions{Remote: attacher})))
t.Cleanup(front.Close)
return front
}
@@ -97,7 +97,7 @@ func TestRemovingAHostClosesItsIdlePanes(t *testing.T) {
}, nil); err != nil {
t.Fatal(err)
}
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp := bookPost(t, front, "/remotes/remove", `{"name":"gpu-box"}`)
@@ -125,7 +125,7 @@ func TestRemovingAHostIsRefusedWhileItsPaneRuns(t *testing.T) {
t.Fatal(err)
}
}
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
resp := bookPost(t, front, "/remotes/remove", `{"name":"gpu-box"}`)
@@ -201,7 +201,7 @@ func TestRemoteTreeIsReadWithOrWithoutAnOpenPane(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
var attached, released int
@@ -214,7 +214,7 @@ func TestRemoteTreeIsReadWithOrWithoutAnOpenPane(t *testing.T) {
}, func() { released++ }, nil
},
}})
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
panes := len(near.Runtimes())
@@ -391,7 +391,7 @@ func TestOpeningARemoteFolderWritesItIntoTheBook(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
workspace := testenv.TempDir(t)
@@ -406,7 +406,7 @@ func TestOpeningARemoteFolderWritesItIntoTheBook(t *testing.T) {
},
}})
defer near.Shutdown()
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
if got := bookPost(t, nearSide, "/remotes", `{"name":"gpu-box","host":"10.0.0.4"}`).StatusCode; got != http.StatusNoContent {
t.Fatalf("save host = %d", got)
@@ -444,7 +444,7 @@ func TestAFolderWithAnIdlePaneOnItIsDroppedWithThePane(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
workspace := testenv.TempDir(t)
@@ -457,7 +457,7 @@ func TestAFolderWithAnIdlePaneOnItIsDroppedWithThePane(t *testing.T) {
},
}})
defer near.Shutdown()
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
bookPost(t, nearSide, "/remotes", `{"name":"gpu-box","host":"10.0.0.4"}`)
@@ -491,7 +491,7 @@ func TestReopeningAKnownFolderLeavesTheConfigAlone(t *testing.T) {
writeOpenableConfig(t)
far := NewHub(HubOptions{})
defer far.Shutdown()
farSide := httptest.NewServer(far.Handler())
farSide := httptest.NewServer(operatorHandler(far))
defer farSide.Close()
workspace := testenv.TempDir(t)
@@ -504,7 +504,7 @@ func TestReopeningAKnownFolderLeavesTheConfigAlone(t *testing.T) {
},
}})
defer near.Shutdown()
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
bookPost(t, nearSide, "/remotes", `{"name":"gpu-box","host":"10.0.0.4"}`)
@@ -562,7 +562,7 @@ func TestRemoteSessionIsRemovedByTheFarKernel(t *testing.T) {
},
}})
defer near.Shutdown()
nearSide := httptest.NewServer(near.Handler())
nearSide := httptest.NewServer(operatorHandler(near))
defer nearSide.Close()
remove := func() *http.Response {
+5 -5
View File
@@ -30,7 +30,7 @@ func readRoles(t *testing.T, base string) map[string]string {
// model" rather than a missing answer.
func TestRolesDefaultToTheMainModel(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
roles := readRoles(t, srv.URL)
@@ -50,7 +50,7 @@ func TestEveryRoleTheUIOffersSurvivesARoundTrip(t *testing.T) {
t.Run(role, func(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/roles", `{"role":"`+role+`","ref":"existing/model-a"}`)
@@ -69,7 +69,7 @@ func TestEveryRoleTheUIOffersSurvivesARoundTrip(t *testing.T) {
func TestSetRoleWritesTheAssignmentAndReadsBack(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/roles", `{"role":"subagent","ref":"existing/model-a"}`)
@@ -106,7 +106,7 @@ func TestSetRoleWritesTheAssignmentAndReadsBack(t *testing.T) {
func TestSetRoleRejectsWhatItCannotResolve(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for name, body := range map[string]string{
@@ -125,7 +125,7 @@ func TestSetRoleRejectsWhatItCannotResolve(t *testing.T) {
// wait on the same grant provider edits do.
func TestSetRoleIsRefusedUntilTheHostGrantsIt(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/roles", `{"role":"planner","ref":"existing/model-a"}`)
+1 -1
View File
@@ -459,7 +459,7 @@ func (s *Server) Handler() http.Handler {
}
func (s *Server) handler() http.Handler {
return logMiddleware(s.auth.middleware(csrfGuard(s.routes())))
return logMiddleware(s.auth.middleware(s.auth.hostGuard(csrfGuard(s.auth.mutationGate(s.routes())))))
}
func (s *Server) reloadExtensionsHTTP(w http.ResponseWriter, r *http.Request) {
+5 -5
View File
@@ -55,7 +55,7 @@ func TestResumeReadsASessionAnotherRuntimeHolds(t *testing.T) {
t.Fatalf("seed lease on active: %v", err)
}
server.SetSessionLeases(leases)
srv := httptest.NewServer(server.Handler())
srv := httptest.NewServer(operatorHandler(server))
defer srv.Close()
body, err := json.Marshal(map[string]string{"path": held})
@@ -105,7 +105,7 @@ func TestResumeMovesSessionLease(t *testing.T) {
t.Fatalf("seed lease on active: %v", err)
}
server.SetSessionLeases(leases)
srv := httptest.NewServer(server.Handler())
srv := httptest.NewServer(operatorHandler(server))
defer srv.Close()
body, err := json.Marshal(map[string]string{"path": next})
@@ -204,7 +204,7 @@ func TestConcurrentResumesKeepControllerAndLeaseAligned(t *testing.T) {
t.Fatalf("seed lease on active: %v", err)
}
server.SetSessionLeases(leases)
srv := httptest.NewServer(server.Handler())
srv := httptest.NewServer(operatorHandler(server))
defer srv.Close()
client := &http.Client{Timeout: 10 * time.Second}
@@ -274,7 +274,7 @@ func TestConcurrentResumeAndNewKeepAlignment(t *testing.T) {
t.Fatalf("seed lease on active: %v", err)
}
server.SetSessionLeases(leases)
srv := httptest.NewServer(server.Handler())
srv := httptest.NewServer(operatorHandler(server))
defer srv.Close()
client := &http.Client{Timeout: 10 * time.Second}
@@ -350,7 +350,7 @@ func TestInterleavedResumesForcedThroughBindWindow(t *testing.T) {
t.Fatalf("seed lease on active: %v", err)
}
server.SetSessionLeases(leases)
srv := httptest.NewServer(server.Handler())
srv := httptest.NewServer(operatorHandler(server))
defer srv.Close()
client := &http.Client{Timeout: 10 * time.Second}
+20 -20
View File
@@ -112,7 +112,7 @@ func TestServeSubmitRunsAndBroadcastsTurnDone(t *testing.T) {
bc := NewBroadcaster()
got := make(chan string, 1)
ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
sub, cancel := bc.Subscribe() // observe the broadcast deterministically
@@ -153,7 +153,7 @@ func TestServeSubmitRunsAndBroadcastsTurnDone(t *testing.T) {
func TestServeEndpoints(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc}) // no runner needed for these
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
if resp, err := http.Get(srv.URL + "/history"); err != nil || resp.StatusCode != http.StatusOK {
@@ -216,7 +216,7 @@ func TestServeSubmitRejectsShellShortcut(t *testing.T) {
bc := NewBroadcaster()
got := make(chan string, 1)
ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{"input":"!echo nope"}`))
@@ -238,7 +238,7 @@ func TestServeSubmitValidatesFormat(t *testing.T) {
bc := NewBroadcaster()
got := make(chan string, 1)
ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
post := func(body string) int {
@@ -363,7 +363,7 @@ func TestSessionsListPreviewSeesEventLogTurns(t *testing.T) {
func TestServeCancelEndpoint(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/cancel", "application/json", nil)
@@ -383,7 +383,7 @@ func TestServeCancelEndpoint(t *testing.T) {
func TestServeGoalEndpointSetsAndClears(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/goal", "application/json", strings.NewReader(`{"goal":"ship the release"}`))
@@ -414,7 +414,7 @@ func TestServeGoalEndpointSetsAndClears(t *testing.T) {
func TestServeApproveMissingID(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
// Missing id should return 400.
@@ -438,7 +438,7 @@ func TestServeApproveMissingID(t *testing.T) {
func TestServeNewSessionEndpoint(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/new", "application/json", nil)
@@ -454,7 +454,7 @@ func TestServeNewSessionEndpoint(t *testing.T) {
func TestServeCompactEndpoint(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/compact", "application/json", nil)
@@ -478,7 +478,7 @@ func TestServeModelsMarksActiveByModelRef(t *testing.T) {
Label: "shared-chat",
ModelRef: "alternate/shared-chat",
})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/models")
@@ -528,7 +528,7 @@ func TestServeModelsIncludesExtensionProviderCatalog(t *testing.T) {
}},
},
})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/models")
@@ -676,7 +676,7 @@ func TestResumeRequiresSessionPathInsideSessionDir(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
post := func(path string) int {
@@ -721,7 +721,7 @@ func TestResumeRejectsCleanupPendingSession(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
body, err := json.Marshal(map[string]string{"path": pending})
@@ -756,7 +756,7 @@ func TestSessionsSkipsCleanupPending(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/sessions")
@@ -805,7 +805,7 @@ func TestDeleteSessionRequiresSessionNameInsideSessionDir(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
post := func(body string) int {
@@ -872,7 +872,7 @@ func writeServeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
func TestServeSubmitMalformedJSON(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{not json`))
@@ -888,7 +888,7 @@ func TestServeSubmitMalformedJSON(t *testing.T) {
func TestServePlanMalformedJSON(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/plan", "application/json", strings.NewReader(`{bad`))
@@ -904,7 +904,7 @@ func TestServePlanMalformedJSON(t *testing.T) {
func TestServeContextEndpoint(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/context")
@@ -935,7 +935,7 @@ func TestServeEventsReplaysPendingAskOnAttach(t *testing.T) {
bc := NewBroadcaster()
ctrl := control.New(control.Options{Sink: bc})
ctrl.EnableInteractiveApproval()
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
firstSub, cancelFirst := bc.Subscribe()
@@ -1067,7 +1067,7 @@ func TestServeEventsReplaysPendingApprovalOnAttach(t *testing.T) {
Policy: permission.New("ask", nil, nil, nil),
})
ctrl.EnableInteractiveApproval()
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
runDone := make(chan error, 1)
@@ -36,7 +36,7 @@ func TestSessionsMarksCurrentAcrossPathSpelling(t *testing.T) {
SessionPath: filepath.Join(strings.ToUpper(dir), name),
})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/sessions")
@@ -45,7 +45,7 @@ func TestSessionsListReadsSidecarsNotTranscripts(t *testing.T) {
ctrl := control.New(control.Options{SessionDir: dir})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/sessions")
@@ -18,7 +18,7 @@ import (
func listSessionNames(t *testing.T, ctrl *control.Controller) []string {
t.Helper()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/sessions")
if err != nil {
@@ -141,7 +141,7 @@ func TestSessionsExcludesSubagentTranscripts(t *testing.T) {
defer ctrl.Close()
ctrl.SetSessionPath(filepath.Join(dir, "20260813-235122-deepseek-v4-flash.jsonl"))
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/sessions")
if err != nil {
+4 -4
View File
@@ -32,7 +32,7 @@ func readShell(t *testing.T, base string) control.ShellSettings {
// client nothing it could not learn from the next command's own card.
func TestShellSettingsReportsTheRunningInterpreter(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
got := readShell(t, srv.URL)
@@ -48,7 +48,7 @@ func TestShellSettingsReportsTheRunningInterpreter(t *testing.T) {
// machine, so it rides the same grant as the other config-editing routes.
func TestSaveShellRefusedWithoutGrant(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/shell", `{"prefer":"bash"}`)
@@ -61,7 +61,7 @@ func TestSaveShellRefusedWithoutGrant(t *testing.T) {
func TestSaveShellPersistsTheChoice(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/shell", `{"prefer":"bash"}`)
@@ -87,7 +87,7 @@ func TestSaveShellPersistsTheChoice(t *testing.T) {
func TestSaveShellRejectsUnusablePath(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/shell", `{"prefer":"bash","path":"/definitely/not/a/shell"}`)
+4 -4
View File
@@ -51,7 +51,7 @@ func storageRootsOf(t *testing.T, body map[string]any) map[string]map[string]any
// arrive — including the ones it may only show and never offer to move.
func TestStorageListsEveryRootWithItsRelocationFacts(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
roots := storageRootsOf(t, readStorage(t, srv.URL))
@@ -74,7 +74,7 @@ func TestStorageListsEveryRootWithItsRelocationFacts(t *testing.T) {
func TestStoragePlanReportsCostAndRefusalsWithoutMoving(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
before := config.RootDir(config.RootState)
@@ -104,7 +104,7 @@ func TestStoragePlanReportsCostAndRefusalsWithoutMoving(t *testing.T) {
// is the one thing a server reachable over a network must not let a client do.
func TestStorageMoveIsRefusedUntilTheHostGrantsIt(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/storage/move", `{"root":"state","dir":"`+storagePathLiteral(testenv.TempDir(t))+`"}`)
@@ -119,7 +119,7 @@ func TestStorageMoveIsRefusedUntilTheHostGrantsIt(t *testing.T) {
func TestStorageMoveRunsAndReportsThroughTheListing(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
home := config.RootDir(config.RootState)
@@ -16,7 +16,7 @@ func studioServer(t *testing.T, in *update.Install) *httptest.Server {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{Install: in})
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
t.Cleanup(srv.Close)
return srv
}
@@ -30,7 +30,7 @@ func TestSubmitBindsWriteAuthorityToAFreshlyMintedSession(t *testing.T) {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
ts := httptest.NewServer(operatorHandler(srv))
defer ts.Close()
resp, err := http.Post(ts.URL+"/submit", "application/json", strings.NewReader(`{"input":"今日热点"}`))
if err != nil {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func surfaceServer(t *testing.T) string {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
srv := httptest.NewServer(New(&pluginCtl{root: testenv.TempDir(t)}, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(&pluginCtl{root: testenv.TempDir(t)}, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv.URL
}
+1 -1
View File
@@ -30,7 +30,7 @@ func installPack(t *testing.T, id, name string) {
func themeServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(New(&extensionCtl{}, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(&extensionCtl{}, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
+2 -2
View File
@@ -48,7 +48,7 @@ func trayServer(t *testing.T, host TrayHost) *httptest.Server {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{Tray: host})
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
t.Cleanup(srv.Close)
return srv
}
@@ -92,7 +92,7 @@ func trayPut(t *testing.T, srv *httptest.Server, body string) (int, TrayPrefs) {
func TestATraySurfaceExistsOnlyWhereAWindowDoes(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
srv := httptest.NewServer(h.Handler())
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
for _, path := range []string{"/tray/prefs", "/tray/state"} {
+6 -6
View File
@@ -35,7 +35,7 @@ func (s *stubUpdateHost) InstallProgress() update.Progress { return s.progress }
// makes the routes exist, so a hub without an owner has none to refuse.
func TestUpdateRoutesExistOnlyWhereSomethingOwnsTheApplication(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
bare := httptest.NewServer(NewHub(HubOptions{}).Handler())
bare := httptest.NewServer(operatorHandler(NewHub(HubOptions{})))
defer bare.Close()
resp, err := http.Post(bare.URL+"/update/health", "application/json", strings.NewReader(""))
@@ -53,7 +53,7 @@ func TestUpdateRoutesExistOnlyWhereSomethingOwnsTheApplication(t *testing.T) {
func TestAnOwnedApplicationAcknowledgesItsOwnLaunch(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
host := &stubUpdateHost{}
owned := httptest.NewServer(NewHub(HubOptions{Update: host}).Handler())
owned := httptest.NewServer(operatorHandler(NewHub(HubOptions{Update: host})))
defer owned.Close()
resp, err := http.Post(owned.URL+"/update/health", "application/json", strings.NewReader(`{"transaction":"somebody else's"}`))
@@ -77,7 +77,7 @@ func TestAnOwnedApplicationAcknowledgesItsOwnLaunch(t *testing.T) {
func TestInstallRefusesWhereNoInstallWasDeclared(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
host := &stubUpdateHost{}
owned := httptest.NewServer(NewHub(HubOptions{Update: host}).Handler())
owned := httptest.NewServer(operatorHandler(NewHub(HubOptions{Update: host})))
defer owned.Close()
resp, err := http.Post(owned.URL+"/update/install", "application/json", strings.NewReader(`{"version":"v2.0.0"}`))
@@ -100,7 +100,7 @@ func TestInstallCarriesTheHubsDeclaredInstall(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
host := &stubUpdateHost{}
declared := update.Install{Version: "v1.0.0", Layout: update.Layout{Root: "/opt/studio", Executable: "/opt/studio/studio"}}
owned := httptest.NewServer(NewHub(HubOptions{Update: host, Install: &declared}).Handler())
owned := httptest.NewServer(operatorHandler(NewHub(HubOptions{Update: host, Install: &declared})))
defer owned.Close()
resp, err := http.Post(owned.URL+"/update/install", "application/json", strings.NewReader(`{"version":"v2.0.0"}`))
@@ -128,7 +128,7 @@ func TestASecondInstallIsRefusedAsAlreadyRunning(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
declared := update.Install{Version: "v1.0.0", Layout: update.Layout{Root: "/opt/studio"}}
host := &stubUpdateHost{startErr: appupdate.ErrInstallInFlight}
owned := httptest.NewServer(NewHub(HubOptions{Update: host, Install: &declared}).Handler())
owned := httptest.NewServer(operatorHandler(NewHub(HubOptions{Update: host, Install: &declared})))
defer owned.Close()
resp, err := http.Post(owned.URL+"/update/install", "application/json", strings.NewReader(`{"version":"v2.0.0"}`))
@@ -156,7 +156,7 @@ func TestASecondInstallIsRefusedAsAlreadyRunning(t *testing.T) {
func TestInstallProgressIsRead(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
host := &stubUpdateHost{progress: update.Progress{Version: "v2.0.0", Phase: update.PhaseVerifying, Received: 7, Total: 7}}
owned := httptest.NewServer(NewHub(HubOptions{Update: host}).Handler())
owned := httptest.NewServer(operatorHandler(NewHub(HubOptions{Update: host})))
defer owned.Close()
resp, err := http.Get(owned.URL + "/update/install")
+3 -3
View File
@@ -21,7 +21,7 @@ func TestServeIndexPageAndSessionDeepLink(t *testing.T) {
// with the same shell and keep the path, so the page can read it.
for _, path := range []string{"/", "/sessions/reserved-session"} {
rec := httptest.NewRecorder()
server.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
operatorHandler(server).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<title>studio</title>") {
t.Errorf("GET %s = %d %q, want the page shell", path, rec.Code, rec.Body.String())
}
@@ -35,7 +35,7 @@ func TestRootHandsTheVisitorToTheBuiltPage(t *testing.T) {
srv := New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{})
srv.page = fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<title>studio</title>")}}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
operatorHandler(srv).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<title>studio</title>") {
t.Fatalf("GET / = %d %q, want the page shell", rec.Code, rec.Body.String())
}
@@ -47,7 +47,7 @@ func TestRootSaysSoWhenNoPageIsBuilt(t *testing.T) {
bc := NewBroadcaster()
srv := New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
operatorHandler(srv).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("GET / with no page = %d, want 404", rec.Code)
}
@@ -22,7 +22,7 @@ func TestWorkspaceFileEditRequiresTheRevisionItRead(t *testing.T) {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/workspace/file?path=note.txt")
@@ -61,7 +61,7 @@ func TestWorkspaceFileRefusesPathsOutsideTheRoot(t *testing.T) {
dir := testenv.TempDir(t)
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/workspace/file?path=../secret.txt")
if err != nil {
@@ -29,7 +29,7 @@ func imageServer(t *testing.T, files map[string]string) *httptest.Server {
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, WorkspaceRoot: dir})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
@@ -119,7 +119,7 @@ func TestHubRefusesToLocateInARemoteWorkspace(t *testing.T) {
rk := fakeRemoteKernel(t)
h := NewHub(HubOptions{})
rt := remotePane(t, h, rk.Listener.Addr().String())
front := httptest.NewServer(h.Handler())
front := httptest.NewServer(operatorHandler(h))
defer front.Close()
for _, path := range []string{"/workspace/locate", "/workspace/locate?path=out", "//workspace/./locate", "/workspace/locate/"} {
+3 -3
View File
@@ -20,7 +20,7 @@ import (
func TestWorkspaceSwitchRefusedWithoutGrant(t *testing.T) {
ctrl := control.New(control.Options{})
defer ctrl.Close()
srv := httptest.NewServer(New(ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Post(srv.URL+"/workspace", "application/json",
@@ -46,7 +46,7 @@ func TestWorkspaceSwitchRebuildsAtNewRoot(t *testing.T) {
gotDir = d
return replacement, nil
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
defer replacement.Close()
@@ -90,7 +90,7 @@ func TestWorkspaceSwitchRejectsMissingDirectory(t *testing.T) {
t.Fatal("a build was attempted for a path that does not exist")
return nil, nil
}
srv := httptest.NewServer(s.Handler())
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
missing := filepath.Join(testenv.TempDir(t), "not-here")
+1 -1
View File
@@ -204,7 +204,7 @@ stalled_warning_seconds = 900 # warn once per background job after this many q
[serve]
# Authentication for the HTTP serve frontend (reasonix serve).
# auth_mode = "none" # none | token | password
# auth_mode = "token" # token (default) | password | none (changes and approvals still need the launch token)
# token = "" # pre-shared token for auth_mode = "token" (auto-generated if empty)
# password_hash = "" # bcrypt hash for auth_mode = "password". Generate with: reasonix serve --hash-password <password>
# behind_proxy = false # set true when behind a trusted reverse proxy (nginx, Caddy, etc.) that sets X-Forwarded-* headers