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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,8 @@ build: generate
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \
fi
@echo "Building embedded web frontend ..."
@cd web/frontend && pnpm install && pnpm build:backend
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa

> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.

### Web launcher dashboard

When you run **picoclaw-launcher** and use the browser UI, you sign in first. On first run the app creates `launcher-config.json` next to your main `config.json`. It stores an access token and a key used for browser sessions—treat the file as sensitive.

- **Where it lives**: Same folder as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is named `launcher-config.json`.
- **Override the token (temporary)**: Set `PICOCLAW_LAUNCHER_TOKEN` to use a different token for this run. It is **not** written back to disk.
- **Everyday use**: Enter the token on the login page. You can also open a login URL that includes the token; after sign-in the token is removed from the address bar (similar to Jupyter).

> **Advanced:** For your own scripts calling the launcher APIs, send `Authorization: Bearer <token>` or reuse an authenticated browser session.

### Skill Sources

By default, skills are loaded from:
Expand Down
2 changes: 1 addition & 1 deletion docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.

> [!WARNING]
> The web console does not yet support authentication. Avoid exposing it to the public internet.
> The web console uses a dashboard token. Treat `dashboard_token` and `auth_signing_key` as secrets and **do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.

### Agent Mode (One-shot)

Expand Down
10 changes: 10 additions & 0 deletions docs/zh/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work

> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。

### Web 启动器控制台

用 **picoclaw-launcher** 在浏览器里打开控制台时,需要先登录。首次运行会在你的主配置文件旁边自动生成 `launcher-config.json`,里面保存访问口令和用于浏览器会话的密钥,请当作敏感信息保管。

- **文件在哪**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用配置的文件名是 `launcher-config.json`。
- **改口令(临时)**:设置环境变量 `PICOCLAW_LAUNCHER_TOKEN` 可指定本次运行使用的口令,**不会**写回文件。
- **平时怎么用**:在浏览器里按登录页提示输入口令即可;也支持在登录页链接里带上口令(登录成功后地址栏里的口令会被去掉,类似 Jupyter)。

> **进阶**:自己写脚本调控制台接口时,可在请求头加上 `Authorization: Bearer <口令>`,或使用已在浏览器里登录过的会话。

### 技能来源 (Skill Sources)

默认情况下,技能会按以下顺序加载:
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ docker compose -f docker/docker-compose.yml --profile gateway down
docker compose -f docker/docker-compose.yml --profile launcher up -d
```

在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。

> [!WARNING]
> Web 控制台尚不支持身份验证。请勿将其暴露到公网
> Web 控制台通过 dashboard 令牌鉴权。请将 `dashboard_token`、`auth_signing_key` 视为密钥,**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节

### Agent 模式 (一次性运行)

Expand Down
2 changes: 1 addition & 1 deletion pkg/config/security_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/require"
)

// Test JSON unmarshal of private fields
// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag).
func TestJSONUnmarshalPrivateFields(t *testing.T) {
type testStruct struct {
PublicField string `json:"public"`
Expand Down
81 changes: 81 additions & 0 deletions web/backend/api/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package api

import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"

"github.com/sipeed/picoclaw/web/backend/middleware"
)

// LauncherAuthRouteOpts configures dashboard token login handlers.
type LauncherAuthRouteOpts struct {
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
}

type launcherAuthLoginBody struct {
Token string `json:"token"`
}

// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("GET /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
}

type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
}

func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
var body launcherAuthLoginBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
in := strings.TrimSpace(body.Token)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
return
}

middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}

func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) {
middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}

func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
ok := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
w.Header().Set("Content-Type", "application/json")
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
return
}
_, _ = w.Write([]byte(`{"authenticated":false}`))
}
116 changes: 109 additions & 7 deletions web/backend/api/gateway_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,118 @@ func requestWSScheme(r *http.Request) string {
return "ws"
}

func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE).
func requestHTTPScheme(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "https"
}
if proto == "http" || proto == "ws" {
return "http"
}
}
if r.TLS != nil {
return "https"
}
return "http"
}

// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers
// (e.g. VS Code port forwarding, nginx). Empty if unset.
func forwardedHostFirst(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if raw == "" {
raw = forwardedRFC7239Host(r)
}
if raw == "" {
return ""
}
if i := strings.IndexByte(raw, ','); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
return raw
}

// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239).
func forwardedRFC7239Host(r *http.Request) string {
v := strings.TrimSpace(r.Header.Get("Forwarded"))
if v == "" {
return ""
}
first := strings.TrimSpace(strings.Split(v, ",")[0])
for _, part := range strings.Split(first, ";") {
part = strings.TrimSpace(part)
low := strings.ToLower(part)
if !strings.HasPrefix(low, "host=") {
continue
}
val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:])
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
val = val[1 : len(val)-1]
}
return val
}
return ""
}

// forwardedPortFirst returns the first X-Forwarded-Port value, or empty.
func forwardedPortFirst(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port"))
if raw == "" {
return ""
}
if i := strings.IndexByte(raw, ','); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
return raw
}

// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies).
func clientVisiblePort(r *http.Request, serverListenPort int) string {
if p := forwardedPortFirst(r); p != "" {
return p
}
if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
return port
}
if requestHTTPScheme(r) == "https" {
return "443"
}
return strconv.Itoa(serverListenPort)
}

// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser.
func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string {
if h, p, err := net.SplitHostPort(host); err == nil {
return net.JoinHostPort(h, p)
}
// Use web server port instead of gateway port to avoid exposing extra ports
// The WebSocket connection will be proxied by the backend to the gateway
return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort))
}

// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send).
// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise
// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails.
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
wsPort = 18800 // default web server port
}
return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws"
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
host := requestHostName(r)
return net.JoinHostPort(host, strconv.Itoa(wsPort))
}

func (h *Handler) buildWsURL(r *http.Request) string {
return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws"
}

func (h *Handler) buildPicoEventsURL(r *http.Request) string {
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events"
}

func (h *Handler) buildPicoSendURL(r *http.Request) string {
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send"
}
62 changes: 58 additions & 4 deletions web/backend/api/gateway_host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,16 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "192.168.1.9:18800"

if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" {
if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
}

if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" {
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events")
}
if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" {
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send")
}
}

func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
Expand Down Expand Up @@ -147,7 +154,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
req.Host = "chat.example.com"
req.Header.Set("X-Forwarded-Proto", "https")

if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
}
}
Expand All @@ -164,11 +171,45 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{}

if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
}
}

func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}

h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)

cfg := config.DefaultConfig()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790

req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Port", "443")

if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" {
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events")
}
if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" {
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send")
}
if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws")
}
}

func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
Expand All @@ -182,7 +223,20 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
req.TLS = &tls.ConnectionState{}
req.Header.Set("X-Forwarded-Proto", "http")

if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
}
}

func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)

req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil)
req.Host = "localhost:18800"

if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
}
}
Loading