Skip to content
Merged
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
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ make run # build + launch
## Development

```bash
./vibecockpit --web --port 3456 & # API backend
./vibecockpit --web --port 3456 & # API backend (loopback only by default)
cd frontend && npm run dev # Svelte with hot-reload (proxies /api to :3456)

# Headless / LAN mode — requires a token
VIBECOCKPIT_TOKEN=$(openssl rand -hex 32) ./vibecockpit --web --bind 0.0.0.0 --port 3456
```

## Quality
Expand Down Expand Up @@ -78,10 +81,12 @@ frontend/src/
- Agent tracker with PID, elapsed time, log tailing, kill support

### MCP server (`internal/mcp/`)
- 13+ tools: sessions, boards, costs, stats, inventory, rescan
- 14+ tools: sessions, boards, costs, stats, inventory, rescan, search_memory, get_session_context
- Board tools: list_boards, list_tasks, get_task, update_task, create_task
- Inventory filtering by type and query
- Run via `vibecockpit --mcp`
- Two transports, same Server, same toolset:
- Stdio: `vibecockpit --mcp` (one process per client, JSON-RPC over stdin/stdout)
- HTTP: `POST /mcp` on the web server (Streamable HTTP transport; reuses `Server.HandleHTTPRequest`)

### Scan cache (`internal/web/scancache.go`)
- Directory mtime-based invalidation
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,58 @@ VibeCockpit extracts token usage from your session files and estimates costs usi
| Antigravity | Conversations encrypted, no token data | N/A |
| Remote (SSH) | Not yet supported | — |

## Remote / Headless Mode

VibeCockpit can run headless on a server (e.g. a home lab or VPS) and act as a central memory hub for multiple laptops.

```bash
# On the server — bind to LAN, require a token
export VIBECOCKPIT_TOKEN=$(openssl rand -hex 32)
vibecockpit --web --bind 0.0.0.0 --port 3456 --autostart

# On each laptop — pull memory periodically and ship it to the server
vibecockpit memory export ~/.cache/vc.db
curl -X POST -H "Authorization: Bearer $VIBECOCKPIT_TOKEN" \
-F "file=@$HOME/.cache/vc.db" \
http://server.local:3456/api/memory/import
```

By default the web UI binds to `127.0.0.1` only. `--bind 0.0.0.0` (or any non-loopback address) requires `--token` (or `VIBECOCKPIT_TOKEN`); VibeCockpit refuses to start without one — there's no other auth on the API.

For sensitive networks, terminate TLS in front of VibeCockpit (Caddy, nginx, Traefik, Tailscale Funnel) rather than exposing plain HTTP. Loopback requests (including SSH-tunneled ones) still bypass the token, so `ssh -L 3456:localhost:3456 server` keeps working without any client config.

### MCP over HTTP

When `enable_mcp: true` is set in `config.yaml`, the same MCP toolset that `vibecockpit --mcp` exposes over stdio is also served at `POST /mcp` (the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport). Agents on remote machines can hit this endpoint to query the server's memory index without each laptop having to maintain its own copy.

Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):

```json
{
"mcpServers": {
"vibecockpit-remote": {
"url": "http://server.local:3456/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
```

Cursor's `~/.cursor/mcp.json`:

```json
{
"mcpServers": {
"vibecockpit-remote": {
"url": "http://server.local:3456/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
```

Once configured, `search_memory` and `get_session_context` queries from any client hit the central index — every machine sees the same cross-tool memory.

## Contributing

VibeCockpit is built with **Go** (backend + TUI) and **Svelte 5** (web UI).
Expand Down Expand Up @@ -259,6 +311,8 @@ vibecockpit [flags]

--web Start the web UI (opens in browser)
--port int Port for the web UI (default: 3456)
--bind string Address to bind the web UI to (default: 127.0.0.1; use 0.0.0.0 for LAN)
--token string Bearer token required for non-loopback requests (or set VIBECOCKPIT_TOKEN)
--list List all sessions (table format)
--list --json List all sessions (JSON format)
--version Print version
Expand Down
27 changes: 27 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -51,6 +52,32 @@ func NewServer(providers []provider.Provider, version, workspaceDir string, cfg
return s
}

// HandleHTTPRequest is the entry point for MCP-over-HTTP transport.
// It parses one JSON-RPC request, dispatches it through the same
// handle() the stdio path uses, and returns the JSON-encoded response
// (with the trailing newline from writeResult/writeError stripped).
//
// Returns nil bytes when the request is a JSON-RPC notification (no
// id field, or a notifications/* method) so the HTTP caller can reply
// with 204 No Content. Always returns nil error — protocol-level
// failures are encoded as JSON-RPC error responses.
func (s *Server) HandleHTTPRequest(body []byte) []byte {
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
var buf bytes.Buffer
writeError(&buf, nil, -32700, "Parse error")
return bytes.TrimRight(buf.Bytes(), "\n")
}
var buf bytes.Buffer
s.handle(&buf, &req)
out := bytes.TrimRight(buf.Bytes(), "\n")
if len(out) == 0 {
// notification — no response per JSON-RPC 2.0
return nil
}
return out
}

// Run starts the MCP server, reading JSON-RPC from stdin and writing to stdout.
func (s *Server) Run() error {
reader := bufio.NewReader(os.Stdin)
Expand Down
79 changes: 79 additions & 0 deletions internal/web/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package web

import (
"crypto/subtle"
"net"
"net/http"
"strings"
)

// isLoopbackBind reports whether the bind address points at the local
// machine. Used to decide whether --token is mandatory at startup.
// IPv6 ::1 and the wildcard form ::1/128 are normalized via net.ParseIP.
func isLoopbackBind(addr string) bool {
if addr == "" || addr == "localhost" {
return true
}
ip := net.ParseIP(addr)
if ip == nil {
return false
}
return ip.IsLoopback()
}

// bearerAuthMiddleware enforces token auth for non-loopback requests.
//
// Loopback requests (127.0.0.1, ::1) bypass the check entirely so local
// dev, the auto-launched browser, and SSH tunnels keep working without
// having to pass the token around. When a request arrives over a real
// network and a token is configured, we require an exact-match
// Authorization header (constant-time compared) or a ?token= query
// parameter for browser-driven download flows like /api/memory/export.
//
// If token is empty AND the request isn't loopback we 401 — the
// startup check in Start() refuses to bind to a non-loopback address
// without a token, so this is defensive.
func bearerAuthMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopbackRequest(r) {
next.ServeHTTP(w, r)
return
}
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !validBearer(r, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

func isLoopbackRequest(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return false
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

func validBearer(r *http.Request, token string) bool {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got := strings.TrimPrefix(h, "Bearer ")
if subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 {
return true
}
}
// Query-string fallback for download flows (e.g. <a href="/api/memory/export?token=…">).
// Browsers can't easily send custom Authorization headers from a
// plain link click, so this gives a workable escape hatch.
if got := r.URL.Query().Get("token"); got != "" {
if subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 {
return true
}
}
return false
}
97 changes: 97 additions & 0 deletions internal/web/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package web

import (
"io"
"net/http"
"net/http/httptest"
"testing"
)

func TestIsLoopbackBind(t *testing.T) {
cases := map[string]bool{
"": true,
"localhost": true,
"127.0.0.1": true,
"127.0.0.42": true,
"::1": true,
"0.0.0.0": false,
"192.168.1.5": false,
"10.0.0.1": false,
}
for in, want := range cases {
if got := isLoopbackBind(in); got != want {
t.Errorf("isLoopbackBind(%q) = %v; want %v", in, got, want)
}
}
}

func newAuthRequest(remote string, header string, query string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/sessions"+query, nil)
r.RemoteAddr = remote
if header != "" {
r.Header.Set("Authorization", header)
}
return r
}

func TestBearerMiddleware_LoopbackBypass(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(200)
})
h := bearerAuthMiddleware("the-secret", next)

rec := httptest.NewRecorder()
h.ServeHTTP(rec, newAuthRequest("127.0.0.1:54321", "", ""))
if rec.Code != 200 || !called {
t.Errorf("loopback should bypass token; got code=%d called=%v", rec.Code, called)
}
}

func TestBearerMiddleware_RemoteRequiresToken(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
h := bearerAuthMiddleware("the-secret", next)

cases := []struct {
name string
header string
query string
want int
}{
{"no header", "", "", 401},
{"wrong scheme", "Basic abc", "", 401},
{"wrong token", "Bearer wrong", "", 401},
{"correct token", "Bearer the-secret", "", 200},
{"query token correct", "", "?token=the-secret", 200},
{"query token wrong", "", "?token=nope", 401},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, newAuthRequest("10.0.0.1:54321", c.header, c.query))
if rec.Code != c.want {
body, _ := io.ReadAll(rec.Body)
t.Errorf("got code=%d body=%q; want %d", rec.Code, string(body), c.want)
}
})
}
}

func TestBearerMiddleware_NoTokenConfigured_Remote401(t *testing.T) {
// Defensive case: even if Start() fails to refuse a non-loopback bind
// without a token, the middleware itself must still 401 remote
// requests rather than silently allowing them through.
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
h := bearerAuthMiddleware("", next)

rec := httptest.NewRecorder()
h.ServeHTTP(rec, newAuthRequest("10.0.0.1:54321", "", ""))
if rec.Code != 401 {
t.Errorf("expected 401 when no token configured + remote request; got %d", rec.Code)
}
}
Loading
Loading