diff --git a/README.md b/README.md index 4cacf81..3ac46aa 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,8 @@ When startup succeeds, `mw` opens the web page automatically at the serving URL When Portal is enabled, the startup output includes: ``` -Portal dashboard at: - http://0.0.0.0:12345/ -Tailscale: https://my-machine.tail-scale.ts.net/ +[portal] Portal dashboard at: http://0.0.0.0:12345/ +[portal] Tailscale URL: https://my-machine.tail-scale.ts.net/ ``` myworktree uses the **current working directory** to detect the target repo (git root) and derives an isolated per-project data dir from it, so you can manage other projects by running the same binary in a different repo directory. @@ -174,10 +173,11 @@ myworktree instance list myworktree instance stop # config (global auth token) -mw config # interactive guided setup (set/view/clear token) +mw config # interactive guided setup (set/view/clear/regen token) mw config set-auth # set token directly mw config get-auth # view token (masked) mw config clear-auth # clear token +mw config regen # regenerate token (with confirmation) # start with remote access & Portal (IPv6 is explicitly disabled) mw start --listen 0.0.0.0:0 # LAN access, auto-inherits global token @@ -238,18 +238,18 @@ Configure a global auth token once, and all instances automatically inherit it: ```bash mw config -# → Interactive guided setup: [1] set token [2] view token [3] clear token [q] quit +# → Interactive guided setup: [1] set token [2] view token [3] clear token [4] regen token [q] quit # Token stored in ~/.config/myworktree/auth.json (0600 permissions) ``` -The token is stored as plaintext in `auth.json` (0600 permissions). Instance-level `--auth` override takes precedence over the global token. +When `--auth` is not provided and no token exists in `auth.json`, the CLI **automatically generates a random 32-character hex token** and persists it. This ensures instances always have authentication enabled by default. Instance-level `--auth` override takes precedence over the global token. ### Portal Dashboard `mw start --listen 0.0.0.0:0` starts a **Portal Dashboard** on port `12345` (configurable via `--portal-port`). The dashboard: - Lists all running instances across repos with auto-discovery -- Click an instance to jump to its Web UI through the Portal reverse proxy +- Click an instance to jump to its Web UI (directly via instance port — Portal reverse proxy `/s//` is planned but not yet implemented) - Uses **HttpOnly Cookie** (`mw_token`) for authentication — token never appears in URL or JS - **CSRF protection** via double-submit cookie pattern on login/logout endpoints - Cookie has 24-hour **sliding expiration** (refreshed on each auth-successful request) @@ -258,7 +258,7 @@ Set `--portal-port 0` to disable the Portal. ### Tailscale HTTPS -When Tailscale is installed, the Portal holder automatically configures `tailscale serve` to provide HTTPS access via `https://.ts.net`. This is fully automated — zero manual configuration. +> **Note**: Automatic `tailscale serve` configuration is **currently disabled** due to a Tailscale 1.98 CLI bug on macOS where `tailscale serve --bg` reports success but does not actually configure the proxy. The relevant code exists in `internal/portal/portal.go` but is not wired into the production code paths. Users can still securely access Portal via Tailscale IP (`http://100.x.x.x:12345`) over the WireGuard tunnel. Automatic `tailscale serve` management will be re-enabled once Tailscale fixes the upstream bug. ### Network Security diff --git a/README.zh-CN.md b/README.zh-CN.md index 8e63b8a..8ca99ae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -141,9 +141,8 @@ mw 当 Portal 启用时,启动输出包含: ``` -Portal dashboard at: - http://0.0.0.0:12345/ -Tailscale: https://my-machine.tail-scale.ts.net/ +[portal] Portal dashboard at: http://0.0.0.0:12345/ +[portal] Tailscale URL: https://my-machine.tail-scale.ts.net/ ``` myworktree 会用**当前工作目录**定位目标项目(git root),并基于该 git root 计算独立的数据目录,因此要管理其他项目时,只需要在另一个项目仓库目录下运行同一个 myworktree 二进制即可。 @@ -174,10 +173,11 @@ myworktree instance list myworktree instance stop # config(全局认证 Token) -mw config # 交互式引导(设置/查看/清除 Token) +mw config # 交互式引导(设置/查看/清除/重新生成 Token) mw config set-auth # 直接设置 Token mw config get-auth # 查看 Token(掩码显示) mw config clear-auth # 清除 Token +mw config regen # 重新生成 Token(需确认) # 启动并启用远程访问 + Portal mw start --listen 0.0.0.0:0 # LAN 访问,自动继承全局 Token @@ -236,18 +236,18 @@ GitHub Actions(`.github/workflows/go-ci.yml`)会在以下场景运行: ```bash mw config -# → 交互式引导:[1] 设置 Token [2] 查看 Token [3] 清除 Token [q] 退出 +# → 交互式引导:[1] 设置 Token [2] 查看 Token [3] 清除 Token [4] 重新生成 Token [q] 退出 # Token 存储在 ~/.config/myworktree/auth.json(0600 权限) ``` -Token 以明文存储在 `auth.json` 中(0600 权限)。实例级别 `--auth` 参数优先级高于全局 Token。 +当未提供 `--auth` 且 `auth.json` 中无已有 Token 时,CLI 会**自动生成一个随机的 32 字符 hex Token** 并持久化。这确保实例默认启用认证。实例级别 `--auth` 参数优先级高于全局 Token。 ### Portal 仪表板 `mw start --listen 0.0.0.0:0` 会在端口 `12345` 启动 **Portal 仪表板**(可通过 `--portal-port` 自定义)。仪表板功能: - 自动发现并列出所有跨仓库运行中的实例 -- 点击实例通过 Portal 反向代理跳转到其 Web UI +- 点击实例直接跳转到其 Web UI(通过实例端口直连——Portal 反向代理 `/s//` 计划中但尚未实现) - 使用 **HttpOnly Cookie**(`mw_token`)进行认证——Token 不出现在 URL 或 JS 中 - 登录/登出端点采用 **CSRF 防护**(double-submit cookie 模式) - Cookie 具备 24 小时**滑动过期**机制(每次认证成功的请求自动刷新有效期) @@ -256,7 +256,7 @@ Token 以明文存储在 `auth.json` 中(0600 权限)。实例级别 `--auth ### Tailscale HTTPS -当 Tailscale 已安装时,Portal 持有者自动配置 `tailscale serve`,提供 HTTPS 访问(`https://.ts.net`)。全程自动化,无需手动配置。 +> **说明**:自动 `tailscale serve` 配置功能**当前已禁用**,原因是 macOS 上 Tailscale 1.98 CLI 存在 bug:`tailscale serve --bg` 返回成功但实际未配置代理。相关代码存在于 `internal/portal/portal.go` 中但未接入正式代码路径。用户仍可通过 Tailscale IP(`http://100.x.x.x:12345`)经 WireGuard 隧道安全访问 Portal。待 Tailscale 修复上游 bug 后将重新启用自动 `tailscale serve` 管理功能。 ### 网络安全 diff --git a/docs/API.md b/docs/API.md index 12c7f2b..d54f927 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,7 @@ Auth: - For Portal dashboard access, the `mw_token` HttpOnly Cookie is used as the third token source (automatically sent by browser after `/api/auth` login). - Token priority: `Authorization` header → `?token=` query → `mw_token` Cookie. - Prefer the `Authorization` header when possible so tokens do not end up in browser history or shell history. +- **Auto-generate token**: When `--auth` is not provided and no token exists in `auth.json`, the CLI automatically generates a random 32-char hex token, persists it to `~/.config/myworktree/auth.json`, and uses it as the instance auth token. This ensures instances always have auth enabled by default. Common response header: - `X-Myworktree-Server-Rev: ` is returned by API and UI responses. @@ -592,9 +593,11 @@ Always returns 200 (idempotent — successful even if not logged in). Errors: - `403`: CSRF token invalid/expired/used or missing -### Reverse proxy (access instance) +### Reverse proxy (access instance) — planned, not yet implemented `ANY /s//*` +> **Status: Planned but not yet implemented.** Currently the Portal dashboard links directly to instance ports (`http://:/`) instead of using the reverse proxy path. The `/s//*` route handler is not registered in the Portal HTTP server. + **Authentication required** (Cookie `mw_token` or Bearer token). Each successful request refreshes the `mw_token` Cookie's expiration (sliding). Proxies the request to the corresponding instance at `http://127.0.0.1:`. Since the proxy connects via loopback, the instance's auth middleware automatically bypasses token validation. @@ -608,6 +611,19 @@ Errors: - `401`: Not authenticated - `502`: Target instance offline +### Instance login page (instance-level, HTML) +`GET /login`, `POST /login` + +**No authentication required** — this is the login page itself. + +The instance server serves an HTML login form at `/login` for browser-based authentication (separate from the Portal JSON API). Non-loopback browser requests that lack valid auth are redirected to this page. + +- `GET /login` — Returns an HTML login page with password input and form. If the user already has a valid `mw_token` Cookie that matches the global auth config, they are redirected to `/` immediately. +- `POST /login` — Accepts `token` and optional `next` form fields. On successful auth, sets `mw_token` HttpOnly Cookie (Max-Age=86400, SameSite=Lax, Secure on HTTPS) and redirects to the `next` path (or `/` if not provided). **Note**: `/login` is explicitly exempt from `withAuth` middleware rate limiting; no per-IP rate limits apply to this endpoint. + +Errors: +- `401`: Invalid token + ## 8) LLM 配置 ### 获取当前配置 `GET /api/llm/config` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff6aade..93e169d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,7 +21,7 @@ It does **not** analyze project code or prevent concurrent write conflicts insid - `internal/monitor/` — resource stats collector (CPU delta via gopsutil/process.Times, memory via RSS) - `internal/llm/` — LLM API client(OpenAI / Anthropic / OpenAI Compatible),可选,LLM Settings 通过 Web UI 对话框配置 - `internal/config/` — global auth configuration (read/write `auth.json`) -- `internal/portal/` — Portal dashboard (port claiming, instance registry, CSRF state management, HTTP endpoints, reverse proxy; tailscale serve automation code is defined but **currently unused** due to tailscale CLI bug) +- `internal/portal/` — Portal dashboard (port claiming, instance registry, CSRF state management, HTTP endpoints; **reverse proxy `/s//` planned but not yet implemented** — current dashboard links point to instance ports directly; tailscale serve automation code is defined but **currently unused** due to tailscale CLI bug) - `internal/ui/` — embedded static UI. ## 3. Data & persistence @@ -359,7 +359,7 @@ myworktree implements a **dual-layer authentication architecture**: - Optional built-in HTTPS via `--tls-cert/--tls-key` - Redaction on stored backlog (e.g. `sk-...`) -**Proxy authentication bypass**: Portal reverse proxy forwards requests to instances via `127.0.0.1` (loopback), so instances automatically skip auth — users never need to manually pass tokens to individual instances. +**Proxy authentication bypass (planned)**: The planned Portal reverse proxy (`/s//`) will forward requests to instances via `127.0.0.1` (loopback), so instances automatically skip auth. **Currently not yet implemented** — dashboard links connect to instance ports directly. **Tailscale**: WireGuard tunnel provides network-layer encryption. ~~Portal holder automatically manages `tailscale serve` for HTTPS domain access (`https://.ts.net`) with Let's Encrypt certificates.~~ **Currently disabled** — tailscale CLI `serve` command on macOS returns success but does not actually configure the proxy. The `tailscaleServeLoop` goroutine and related `cleanupStaleTailscaleServe()` call are removed from the production code path. Users can still securely access Portal via Tailscale IP (`http://100.x.x.x:12345`) over the WireGuard tunnel. diff --git a/docs/PRD.md b/docs/PRD.md index 737b833..309c551 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -38,14 +38,16 @@ - 命名冲突:如目标分支已存在,自动给 `` 加 `-2/-3` 后缀避免冲突;并支持将既有 worktree **纳入管理(import)**。 ## 6. 安全 -- 默认只监听 `127.0.0.1`。 -- 监听非 loopback(例如 `0.0.0.0` 或局域网 IP)时:必须提供 `--auth`。 +- 默认监听 `0.0.0.0:0`,自动选择端口并持久化。 +- 启动时若未通过 `--auth` 显式提供 Token,则自动生成 32 位随机 Token 并持久化到 `auth.json`(0o600 权限),确保认证始终启用。 - 可选内置 HTTPS:`--tls-cert/--tls-key`。 - **Portal 端口绑定**:Portal 仪表板可绑定到 `0.0.0.0`,用户通过 LAN IP 或 Tailscale 域名访问。非 loopback 访问 Portal 时须通过 `mw_token` Cookie 认证。 - **全局 Token(HttpOnly Cookie + CSRF)**:全局 Token 存储在 `~/.config/myworktree/auth.json`(0600 权限),通过 `mw config` 交互式配置。Portal 仪表板使用 HttpOnly Cookie(`mw_token`)传输 Token——JS 不可读取,防止 XSS 窃取。登录/登出端点采用 double-submit cookie 模式做 CSRF 防护。 +- **速率限制**:实例端认证失败限流 20 次/分钟/IP;Portal 端 CSRF token 生成限流 1 req/s/IP,认证尝试限流 20 次/分钟/IP。 - **Tailscale HTTPS**:~~当 Tailscale 可用时,Portal 持有者自动配置 `tailscale serve` 提供 `https://.ts.net` 域名访问(Let's Encrypt 证书)。~~ **(暂未启用)** 在 macOS 下测试发现 tailscale 1.98 CLI 的 `serve` 命令存在 bug:`tailscale serve --bg ` 返回成功但实际未配置代理、`tailscale serve status` 始终报告 No serve config。相关代码(`tailscaleServeLoop`、`ensureTailscaleServe` 等)已封存不调用,待 tailscale 修复后恢复。Tailscale WireGuard 隧道本身提供网络层加密,仍可通过 `http://100.x.x.x:PORT` 安全访问。 - 涉及宿主机图形界面的快捷动作(例如从侧栏直接打开 Terminal / Finder)只在浏览器通过 `127.0.0.1` / `localhost` 访问时展示;远程访问时隐藏,避免误导用户在远端会话里触发本机 GUI 行为。 - 对应后端接口也强制仅接受 loopback 客户端请求,不能只依赖前端隐藏来形成安全边界。 +- Loopback 判断仅支持 IPv4(`127.x.x.x` / `localhost`);IPv6 地址(含 `::1`)不被识别为 loopback,需走完整的 Token 认证流程。 - 日志/回放脱敏: - env 键名包含 `TOKEN/SECRET/KEY/PASSWORD` 的值写入状态时替换为 `***`。 - 输出回放中按模式脱敏主流 AI key(如 `sk-***`)。 @@ -59,13 +61,13 @@ - WebSocket 握手协议:服务端发送 `{"type":"ready"}`,客户端等待后发送 resize 开始数据流。 - 窗口尺寸传递:前端监听窗口 resize 并通知后端 PTY,确保 TUI 程序正确重绘。 - 智能重绘:前端在收到第一条数据后延迟 50ms 再次发送 resize,确保 TUI 完整刷新。 - - 超时降级:5 秒握手超时后自动降级到 SSE 方案。 + - 超时降级:客户端若在 5 秒内未收到 WebSocket `ready` 握手消息,则自动关闭 WebSocket 并回退到 SSE 方案。 - 运行中的实例在前端按实例维护各自的终端会话;切换标签时隐藏非活动终端,而不是强制断开其 PTY 连接。 - 终端配置:Web TTY 的缓冲区(scrollback)、主题、字体等参数由前端灵活配置,以适应不同的调试和使用场景。 - 规划增强:**Portal Dashboard MVP**(已实现): - 全局 Token 配置(`mw config` 交互式引导,`~/.config/myworktree/auth.json`,`0o600` 权限) - Portal 仪表板(共享入口端口,自动发现所有仓库的运行实例,HttpOnly Cookie 认证,CSRF 防护) - - 反向代理(通过 Portal 统一入口访问各实例,解决跨域 Cookie 问题,支持 WebSocket) + - ~~反向代理(通过 Portal 统一入口访问各实例,解决跨域 Cookie 问题,支持 WebSocket)~~ **(暂未实现)**:计划通过 `/s//` 路径代理到对应实例端口,当前仪表板链接直接指向实例端口 - ~~Tailscale Serve 自动管理(自动配置 `tailscale serve` 提供 `https://.ts.net` 域名访问)~~ **(暂未启用,见 §6 安全说明)** - 双层认证架构(Portal 层 Cookie + CSRF,实例层 loopback 绕过) - 浏览器关闭保护:前端在 `beforeunload` 事件时,无论是否存在运行中实例,均触发浏览器原生确认对话框,防止误操作关闭页面。 @@ -77,12 +79,12 @@ - UI 重连可看到所有已管理对象,并能回放 instance 近期输出。 - 本机访问 Web UI 时,可从侧栏一键打开所选主工作区/worktree 的 Terminal 与 Finder;远程访问时不展示这两个快捷入口。 - **Portal Dashboard MVP**: - - `mw config` 交互式引导可完成全局 Token 的配置、查看(掩码)、清除 + - `mw config` 交互式引导可完成全局 Token 的配置、查看(掩码)、清除、重新生成 - `mw start --listen 0.0.0.0:0` 自动启动 Portal 仪表板,多实例中仅一个持有 Portal 端口 - Portal 仪表板可通过 LAN IP 和 Tailscale 域名访问,显示所有运行实例并可点击跳转 - - Cookie 认证流程:获取 CSRF token → 提交 auth → 获得 HttpOnly Cookie → 访问实例列表/代理 - - 实例通过 Portal 反向代理访问时,loopback 请求自动绕过实例端 auth 中间件 - - 反向代理支持 WebSocket 升级转发 + - Cookie 认证流程:获取 CSRF token → 提交 auth → 获得 HttpOnly Cookie → 访问实例列表 + - ~~实例通过 Portal 反向代理访问时,loopback 请求自动绕过实例端 auth 中间件~~ **(暂未实现)** + - ~~反向代理支持 WebSocket 升级转发~~ **(暂未实现)** - ~~Tailscale 可用时自动配置 `tailscale serve`,提供 HTTPS 域名访问~~ **(暂未启用)** - Portal 持有者崩溃后,其他实例在 10~15 秒内完成故障转移接管 ## 9. LLM 智能分支命名 diff --git a/internal/app/app.go b/internal/app/app.go index 39cb831..d2831e2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,6 +25,7 @@ import ( "syscall" "time" + "myworktree/internal/config" "myworktree/internal/gitx" "myworktree/internal/instance" "myworktree/internal/llm" @@ -584,9 +585,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { w.Header().Set("Content-Type", "text/html; charset=utf-8") token := extractAuthToken(r) - if token == s.cfg.AuthToken { - http.Redirect(w, r, "/", http.StatusFound) - return + if token != "" { + cfg, _ := config.Load() + if cfg != nil && token == cfg.AuthToken { + http.Redirect(w, r, "/", http.StatusFound) + return + } } loginHTML := ` @@ -668,7 +672,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } token := strings.TrimSpace(r.FormValue("token")) - if token != s.cfg.AuthToken { + cfg, err := config.Load() + if err != nil || token != cfg.AuthToken { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -1618,9 +1623,6 @@ func (s *Server) handleMCPCall(w http.ResponseWriter, r *http.Request) { } func (s *Server) withAuth(next http.Handler) http.Handler { - if strings.TrimSpace(s.cfg.AuthToken) == "" { - return next - } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isLoopbackRequest(r) { next.ServeHTTP(w, r) @@ -1634,8 +1636,17 @@ func (s *Server) withAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + cfg, err := config.Load() + if err != nil { + http.Error(w, "auth config unavailable", http.StatusServiceUnavailable) + return + } + if strings.TrimSpace(cfg.AuthToken) == "" { + next.ServeHTTP(w, r) + return + } token := extractAuthToken(r) - if token != s.cfg.AuthToken { + if token != cfg.AuthToken { if !s.allowAuthAttempt(clientIP(r.RemoteAddr)) { http.Error(w, "too many unauthorized attempts", http.StatusTooManyRequests) return diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d280ff7..ca9b1ad 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "myworktree/internal/config" "myworktree/internal/store" ) @@ -797,6 +798,18 @@ func TestHandleMain(t *testing.T) { } func TestWithAuth_LoopbackBypass(t *testing.T) { + tmpDir := t.TempDir() + testConfigPath := filepath.Join(tmpDir, "myworktree", "auth.json") + reset := config.SetPathForTest(func() (string, error) { return testConfigPath, nil }) + defer reset() + + if err := os.MkdirAll(filepath.Dir(testConfigPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(testConfigPath, []byte(`{"auth_token":"test-token"}`), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + nullLogger := log.New(os.Stderr, "", 0) srv, err := New(Config{AuthToken: "test-token"}, nullLogger) if err != nil { @@ -886,6 +899,18 @@ func TestWithAuth_NonLoopbackRequiresToken(t *testing.T) { } func TestWithAuth_CookieToken(t *testing.T) { + tmpDir := t.TempDir() + testConfigPath := filepath.Join(tmpDir, "myworktree", "auth.json") + reset := config.SetPathForTest(func() (string, error) { return testConfigPath, nil }) + defer reset() + + if err := os.MkdirAll(filepath.Dir(testConfigPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(testConfigPath, []byte(`{"auth_token":"test-token"}`), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + nullLogger := log.New(os.Stderr, "", 0) srv, err := New(Config{AuthToken: "test-token"}, nullLogger) if err != nil { @@ -938,6 +963,18 @@ func TestWithAuth_CookieToken(t *testing.T) { } func TestWithAuth_RateLimiting(t *testing.T) { + tmpDir := t.TempDir() + testConfigPath := filepath.Join(tmpDir, "myworktree", "auth.json") + reset := config.SetPathForTest(func() (string, error) { return testConfigPath, nil }) + defer reset() + + if err := os.MkdirAll(filepath.Dir(testConfigPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(testConfigPath, []byte(`{"auth_token":"test-token"}`), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + nullLogger := log.New(os.Stderr, "", 0) srv, err := New(Config{AuthToken: "test-token"}, nullLogger) if err != nil { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 640c7c3..7f74703 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -339,6 +339,8 @@ func configCmd(logger *log.Logger, args []string) error { return configGetAuth() case "clear-auth": return configClearAuth() + case "regen": + return configRegenAuth(logger) default: return fmt.Errorf("unknown config subcommand: %s", args[0]) } @@ -349,6 +351,7 @@ func configInteractive(logger *log.Logger) error { fmt.Println(" [1] Set auth token") fmt.Println(" [2] View auth token") fmt.Println(" [3] Clear auth token") + fmt.Println(" [4] Regenerate auth token") fmt.Println(" [q] Quit") fmt.Print("Choose an option: ") @@ -364,6 +367,8 @@ func configInteractive(logger *log.Logger) error { return configGetAuth() case "3": return configClearAuth() + case "4": + return configRegenAuth(logger) case "q", "Q": return nil default: @@ -422,6 +427,37 @@ func configClearAuth() error { return nil } +func configRegenAuth(logger *log.Logger) error { + gc, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if gc.AuthToken != "" { + fmt.Print("This will replace the existing auth token. Continue? [y/N] ") + br := bufio.NewReader(os.Stdin) + line, _, err := br.ReadLine() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(string(line)), "y") { + fmt.Println("Aborted.") + return nil + } + } + + token, err := generateAuthToken() + if err != nil { + return fmt.Errorf("failed to generate token: %w", err) + } + gc.AuthToken = token + if err := config.Save(gc); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Printf("New auth token: %s\n", token) + fmt.Println("Auth token regenerated successfully.") + return nil +} + func readPassword() (string, error) { data, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d028d71..e568fac 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -179,3 +179,125 @@ func TestResolveGlobalAuthToken_AuthAlreadySet(t *testing.T) { t.Fatalf("expected no warning/log when --auth is explicitly set, got: %s", buf.String()) } } + +func TestConfigRegenAuth_EmptyConfigGeneratesNewToken(t *testing.T) { + tmpDir := t.TempDir() + reset := config.SetPathForTest(testConfigPath(tmpDir)) + defer reset() + + path := filepath.Join(tmpDir, "myworktree", "auth.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{}`), 0o600) + + err := configRegenAuth(nil) + if err != nil { + t.Fatalf("configRegenAuth failed: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load after regen failed: %v", err) + } + if cfg.AuthToken == "" { + t.Fatal("expected non-empty token after regen") + } + if len(cfg.AuthToken) != 32 { + t.Fatalf("expected 32-char hex token, got %d chars: %q", len(cfg.AuthToken), cfg.AuthToken) + } +} + +func TestConfigRegenAuth_NoExistingTokenSkipsConfirmation(t *testing.T) { + tmpDir := t.TempDir() + reset := config.SetPathForTest(testConfigPath(tmpDir)) + defer reset() + + path := filepath.Join(tmpDir, "myworktree", "auth.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{}`), 0o600) + + restore := captureStdin(t, "y\n") + err := configRegenAuth(nil) + restore() + if err != nil { + t.Fatalf("configRegenAuth failed: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load after regen failed: %v", err) + } + if cfg.AuthToken == "" { + t.Fatal("expected non-empty token after regen") + } +} + +func TestConfigRegenAuth_ExistingTokenRequiresConfirmation(t *testing.T) { + tmpDir := t.TempDir() + reset := config.SetPathForTest(testConfigPath(tmpDir)) + defer reset() + + path := filepath.Join(tmpDir, "myworktree", "auth.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{"auth_token":"old-token-value"}`), 0o600) + + restore := captureStdin(t, "n\n") + err := configRegenAuth(nil) + restore() + if err != nil { + t.Fatalf("configRegenAuth failed: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load after aborted regen failed: %v", err) + } + if cfg.AuthToken != "old-token-value" { + t.Fatalf("expected old token to remain unchanged after abort, got %q", cfg.AuthToken) + } +} + +func TestConfigRegenAuth_YesConfirmationProceeds(t *testing.T) { + tmpDir := t.TempDir() + reset := config.SetPathForTest(testConfigPath(tmpDir)) + defer reset() + + path := filepath.Join(tmpDir, "myworktree", "auth.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{"auth_token":"old-token-value"}`), 0o600) + + restore := captureStdin(t, "y\n") + err := configRegenAuth(nil) + restore() + if err != nil { + t.Fatalf("configRegenAuth failed: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load after regen failed: %v", err) + } + if cfg.AuthToken == "old-token-value" { + t.Fatal("expected token to be regenerated, but old token remains") + } + if len(cfg.AuthToken) != 32 { + t.Fatalf("expected 32-char hex token, got %d chars: %q", len(cfg.AuthToken), cfg.AuthToken) + } +} + +func captureStdin(t *testing.T, input string) (restore func()) { + t.Helper() + oldStdin := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe failed: %v", err) + } + go func() { + w.WriteString(input) + w.Close() + }() + os.Stdin = r + return func() { + os.Stdin = oldStdin + r.Close() + } +}