diff --git a/README.md b/README.md
index c5ef1e0..7304917 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ See [docs/AGENT_BUILDER.md](docs/AGENT_BUILDER.md) for comprehensive documentati
|------|-----------------|-------------|
| File System | `fs.NewFsTool(root)` | File operations within root directory |
| Git | `git.NewGitTool(repoRoot)` | Git operations (add, commit, push, etc.) |
-| Web | `web.NewWebTool(workingDir)` | Web automation with headless browser |
+| Web | `web.NewWebTool(workingDir)` | Web automation with browser sessions; headless by default |
| Postgres | `postgres.NewPostgresTool(url, mode, tables, schemas)` | Database operations with whitelisting |
| API | `api.NewApiTool(name, endpoints, authHook)` | HTTP API calls with auth support |
| Vector | `vector.NewVectorTool(db, embeddings)` | Semantic search and indexing |
diff --git a/VERSION b/VERSION
index a918a2a..ee6cdce 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.6.0
+0.6.1
diff --git a/cmd/localforge/src/auth/config.go b/cmd/localforge/src/auth/config.go
new file mode 100644
index 0000000..889d09e
--- /dev/null
+++ b/cmd/localforge/src/auth/config.go
@@ -0,0 +1,71 @@
+package auth
+
+import (
+ "net/http"
+ "os"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultCookieName = "localforge_session"
+ DefaultSessionTTL = 24 * time.Hour
+)
+
+type Config struct {
+ Enabled bool
+ Username string
+ PasswordHash string
+ CookieName string
+ SessionTTL time.Duration
+ SecureCookie bool
+}
+
+func LoadConfigFromEnv() Config {
+ cfg := Config{
+ Username: strings.TrimSpace(os.Getenv("AUTH_USERNAME")),
+ PasswordHash: strings.TrimSpace(os.Getenv("AUTH_PASSWORD_HASH")),
+ CookieName: DefaultCookieName,
+ SessionTTL: DefaultSessionTTL,
+ SecureCookie: parseBoolEnv("AUTH_COOKIE_SECURE"),
+ }
+
+ if cookieName := strings.TrimSpace(os.Getenv("AUTH_COOKIE_NAME")); cookieName != "" {
+ cfg.CookieName = cookieName
+ }
+
+ if ttlRaw := strings.TrimSpace(os.Getenv("AUTH_SESSION_TTL")); ttlRaw != "" {
+ if ttl, err := time.ParseDuration(ttlRaw); err == nil && ttl > 0 {
+ cfg.SessionTTL = ttl
+ }
+ }
+
+ cfg.Enabled = cfg.Username != "" && cfg.PasswordHash != ""
+ return cfg
+}
+
+func parseBoolEnv(key string) bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
+ case "1", "true", "yes", "on":
+ return true
+ default:
+ return false
+ }
+}
+
+func (c Config) CookieMaxAgeSeconds() int {
+ return int(c.SessionTTL.Seconds())
+}
+
+func (c Config) ShouldUseSecureCookie(r *http.Request) bool {
+ if c.SecureCookie {
+ return true
+ }
+ if r == nil {
+ return false
+ }
+ if r.TLS != nil {
+ return true
+ }
+ return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
diff --git a/cmd/localforge/src/auth/middleware.go b/cmd/localforge/src/auth/middleware.go
new file mode 100644
index 0000000..f4de979
--- /dev/null
+++ b/cmd/localforge/src/auth/middleware.go
@@ -0,0 +1,77 @@
+package auth
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+const sessionContextKey = "localforge.auth.session"
+
+type UnauthorizedMode int
+
+const (
+ UnauthorizedJSON UnauthorizedMode = iota
+ UnauthorizedRedirect
+)
+
+func Middleware(cfg Config, store *SessionStore, mode UnauthorizedMode) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !cfg.Enabled {
+ c.Next()
+ return
+ }
+
+ token, err := c.Cookie(cfg.CookieName)
+ if err == nil && token != "" {
+ session, ok := store.Get(token)
+ if ok {
+ c.Set(sessionContextKey, session)
+ c.Next()
+ return
+ }
+
+ clearSessionCookie(c, cfg)
+ }
+
+ switch mode {
+ case UnauthorizedRedirect:
+ next := SafeNextPath(c.Request.URL.RequestURI())
+ c.Redirect(http.StatusFound, "/login?next="+url.QueryEscape(next))
+ c.Abort()
+ default:
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ }
+ }
+}
+
+func CurrentSession(c *gin.Context) (Session, bool) {
+ value, ok := c.Get(sessionContextKey)
+ if !ok {
+ return Session{}, false
+ }
+
+ session, ok := value.(Session)
+ return session, ok
+}
+
+func clearSessionCookie(c *gin.Context, cfg Config) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: cfg.CookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: cfg.ShouldUseSecureCookie(c.Request),
+ })
+}
+
+func SafeNextPath(next string) string {
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/"
+ }
+ return next
+}
diff --git a/cmd/localforge/src/auth/password.go b/cmd/localforge/src/auth/password.go
new file mode 100644
index 0000000..97622ae
--- /dev/null
+++ b/cmd/localforge/src/auth/password.go
@@ -0,0 +1,10 @@
+package auth
+
+import "golang.org/x/crypto/bcrypt"
+
+func VerifyPassword(password, hash string) bool {
+ if password == "" || hash == "" {
+ return false
+ }
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
diff --git a/cmd/localforge/src/auth/session_store.go b/cmd/localforge/src/auth/session_store.go
new file mode 100644
index 0000000..013416e
--- /dev/null
+++ b/cmd/localforge/src/auth/session_store.go
@@ -0,0 +1,106 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "sync"
+ "time"
+)
+
+type Session struct {
+ Username string
+ TokenHash string
+ ExpiresAt time.Time
+}
+
+type SessionStore struct {
+ mu sync.RWMutex
+ sessions map[string]Session
+}
+
+func NewSessionStore() *SessionStore {
+ return &SessionStore{
+ sessions: make(map[string]Session),
+ }
+}
+
+func (s *SessionStore) Create(username string, ttl time.Duration) (string, Session, error) {
+ token, err := randomToken()
+ if err != nil {
+ return "", Session{}, err
+ }
+
+ session := Session{
+ Username: username,
+ TokenHash: hashToken(token),
+ ExpiresAt: time.Now().UTC().Add(ttl),
+ }
+
+ s.mu.Lock()
+ s.sessions[session.TokenHash] = session
+ s.mu.Unlock()
+
+ return token, session, nil
+}
+
+func (s *SessionStore) Get(token string) (Session, bool) {
+ tokenHash := hashToken(token)
+
+ s.mu.RLock()
+ session, ok := s.sessions[tokenHash]
+ s.mu.RUnlock()
+ if !ok {
+ return Session{}, false
+ }
+ if time.Now().UTC().After(session.ExpiresAt) {
+ s.Delete(token)
+ return Session{}, false
+ }
+ return session, true
+}
+
+func (s *SessionStore) Delete(token string) {
+ s.mu.Lock()
+ delete(s.sessions, hashToken(token))
+ s.mu.Unlock()
+}
+
+func (s *SessionStore) CleanupExpired() {
+ now := time.Now().UTC()
+
+ s.mu.Lock()
+ for key, session := range s.sessions {
+ if now.After(session.ExpiresAt) {
+ delete(s.sessions, key)
+ }
+ }
+ s.mu.Unlock()
+}
+
+func (s *SessionStore) StartCleanup(interval time.Duration) {
+ if interval <= 0 {
+ interval = time.Hour
+ }
+
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for range ticker.C {
+ s.CleanupExpired()
+ }
+ }()
+}
+
+func hashToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+func randomToken() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
diff --git a/cmd/localforge/src/handlers_auth.go b/cmd/localforge/src/handlers_auth.go
new file mode 100644
index 0000000..0c31b6e
--- /dev/null
+++ b/cmd/localforge/src/handlers_auth.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "crypto/subtle"
+ "io/fs"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ localauth "github.com/thinktwiceco/agent-forge/cmd/localforge/src/auth"
+)
+
+func (s *Server) handleLoginPage(c *gin.Context) {
+ if !s.authConfig.Enabled {
+ c.Redirect(http.StatusFound, "/")
+ return
+ }
+
+ if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
+ if _, ok := s.sessionStore.Get(token); ok {
+ c.Redirect(http.StatusFound, requestedNextPath(c))
+ return
+ }
+ }
+
+ data, err := fs.ReadFile(s.staticFS, "login.html")
+ if err != nil {
+ c.String(http.StatusNotFound, "login.html not found")
+ return
+ }
+ c.Data(http.StatusOK, "text/html; charset=utf-8", data)
+}
+
+func (s *Server) handleAuthLogin(c *gin.Context) {
+ if !s.authConfig.Enabled {
+ c.JSON(http.StatusNotFound, gin.H{"error": "authentication is disabled"})
+ return
+ }
+
+ var req LoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
+ return
+ }
+
+ if subtle.ConstantTimeCompare([]byte(req.Username), []byte(s.authConfig.Username)) != 1 ||
+ !localauth.VerifyPassword(req.Password, s.authConfig.PasswordHash) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
+ return
+ }
+
+ token, session, err := s.sessionStore.Create(s.authConfig.Username, s.authConfig.SessionTTL)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
+ return
+ }
+
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: s.authConfig.CookieName,
+ Value: token,
+ Path: "/",
+ MaxAge: s.authConfig.CookieMaxAgeSeconds(),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: s.authConfig.ShouldUseSecureCookie(c.Request),
+ })
+
+ c.JSON(http.StatusOK, AuthStatusResponse{
+ Enabled: true,
+ Authenticated: true,
+ Username: session.Username,
+ Next: requestedNextPath(c),
+ })
+}
+
+func (s *Server) handleAuthLogout(c *gin.Context) {
+ if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
+ s.sessionStore.Delete(token)
+ }
+
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: s.authConfig.CookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: s.authConfig.ShouldUseSecureCookie(c.Request),
+ })
+
+ c.Status(http.StatusNoContent)
+}
+
+func (s *Server) handleAuthMe(c *gin.Context) {
+ resp := AuthStatusResponse{
+ Enabled: s.authConfig.Enabled,
+ Next: requestedNextPath(c),
+ }
+ if !s.authConfig.Enabled {
+ c.JSON(http.StatusOK, resp)
+ return
+ }
+
+ if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
+ if session, ok := s.sessionStore.Get(token); ok {
+ resp.Authenticated = true
+ resp.Username = session.Username
+ }
+ }
+
+ c.JSON(http.StatusOK, resp)
+}
+
+func requestedNextPath(c *gin.Context) string {
+ return localauth.SafeNextPath(c.Query("next"))
+}
diff --git a/cmd/localforge/src/server.go b/cmd/localforge/src/server.go
index 08c5783..9d88956 100644
--- a/cmd/localforge/src/server.go
+++ b/cmd/localforge/src/server.go
@@ -9,9 +9,12 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
+ "time"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
+ localauth "github.com/thinktwiceco/agent-forge/cmd/localforge/src/auth"
"github.com/thinktwiceco/agent-forge/cmd/localforge/src/providers"
)
@@ -30,11 +33,15 @@ type Server struct {
knowledgeDB *sql.DB // opened once at startup; nil if DB not yet available
devMode bool
appDir string
+ staticFS fs.FS
+ authConfig localauth.Config
+ sessionStore *localauth.SessionStore
}
func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoManager, devMode bool, appDir string) *Server {
+ authConfig := localauth.LoadConfigFromEnv()
engine := gin.New()
- engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
+ engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware(authConfig.Enabled))
// Initialize provider registry
providerRegistry := NewProviderRegistry()
@@ -59,6 +66,17 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa
providerRegistry: providerRegistry,
devMode: devMode,
appDir: appDir,
+ authConfig: authConfig,
+ sessionStore: localauth.NewSessionStore(),
+ }
+
+ staticFS, err := server.staticFileSystem()
+ if err != nil {
+ panic(err)
+ }
+ server.staticFS = staticFS
+ if authConfig.Enabled {
+ server.sessionStore.StartCleanup(time.Hour)
}
// Open the knowledge DB once so all handlers share a connection pool.
@@ -101,14 +119,9 @@ func (s *Server) staticFileSystem() (fs.FS, error) {
}
func (s *Server) setupRoutes() {
- staticFS, err := s.staticFileSystem()
- if err != nil {
- panic(err)
- }
-
// Serve static files. In dev mode wrap the handler to disable browser caching
// so file edits are visible immediately without a hard refresh.
- staticHandler := http.StripPrefix("/static", http.FileServer(http.FS(staticFS)))
+ staticHandler := http.StripPrefix("/static", http.FileServer(http.FS(s.staticFS)))
if s.devMode {
original := staticHandler
staticHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -122,35 +135,23 @@ func (s *Server) setupRoutes() {
s.engine.HEAD("/static/*filepath", func(c *gin.Context) {
staticHandler.ServeHTTP(c.Writer, c.Request)
})
+ s.engine.GET("/login", s.handleLoginPage)
- s.engine.GET("/", func(c *gin.Context) {
- data, err := fs.ReadFile(staticFS, "index.html")
- if err != nil {
- c.String(http.StatusNotFound, "index.html not found")
- return
- }
- c.Data(http.StatusOK, "text/html; charset=utf-8", data)
- })
-
- s.engine.GET("/knowledge", func(c *gin.Context) {
- data, err := fs.ReadFile(staticFS, "knowledge.html")
- if err != nil {
- c.String(http.StatusNotFound, "knowledge.html not found")
- return
- }
- c.Data(http.StatusOK, "text/html; charset=utf-8", data)
- })
+ pageRoutes := s.engine.Group("")
+ pageRoutes.Use(localauth.Middleware(s.authConfig, s.sessionStore, localauth.UnauthorizedRedirect))
+ pageRoutes.GET("/", s.serveStaticPage("index.html"))
+ pageRoutes.GET("/knowledge", s.serveStaticPage("knowledge.html"))
+ pageRoutes.GET("/settings", s.serveStaticPage("settings.html"))
- s.engine.GET("/settings", func(c *gin.Context) {
- data, err := fs.ReadFile(staticFS, "settings.html")
- if err != nil {
- c.String(http.StatusNotFound, "settings.html not found")
- return
- }
- c.Data(http.StatusOK, "text/html; charset=utf-8", data)
- })
+ publicAPI := s.engine.Group("/api")
+ publicAPI.GET("/auth/me", s.handleAuthMe)
+ publicAPI.POST("/auth/login", s.handleAuthLogin)
+ publicAPI.POST("/auth/logout", s.handleAuthLogout)
+ publicAPI.POST("/webhooks/:provider", s.handleWebhook)
+ publicAPI.POST("/webhooks/:provider/sync", s.handleWebhookSync)
api := s.engine.Group("/api")
+ api.Use(localauth.Middleware(s.authConfig, s.sessionStore, localauth.UnauthorizedJSON))
api.POST("/chat", s.handleChat)
api.POST("/chat/stop", s.handleStopChat)
api.POST("/upload", s.handleUpload)
@@ -177,10 +178,6 @@ func (s *Server) setupRoutes() {
api.GET("/knowledge/graph", s.handleGetKnowledgeGraph)
api.GET("/knowledge/stats", s.handleGetKnowledgeStats)
api.GET("/knowledge/node/:id", s.handleGetKnowledgeNode)
-
- // Webhook endpoints
- api.POST("/webhooks/:provider", s.handleWebhook)
- api.POST("/webhooks/:provider/sync", s.handleWebhookSync)
}
func (s *Server) Run(port string) error {
@@ -204,11 +201,29 @@ func (s *Server) Shutdown(ctx context.Context) error {
return s.httpSrv.Shutdown(ctx)
}
-func corsMiddleware() gin.HandlerFunc {
+func (s *Server) serveStaticPage(name string) gin.HandlerFunc {
return func(c *gin.Context) {
- c.Header("Access-Control-Allow-Origin", "*")
- c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
- c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ data, err := fs.ReadFile(s.staticFS, name)
+ if err != nil {
+ c.String(http.StatusNotFound, "%s not found", name)
+ return
+ }
+ c.Data(http.StatusOK, "text/html; charset=utf-8", data)
+ }
+}
+
+func corsMiddleware(authEnabled bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !authEnabled {
+ c.Header("Access-Control-Allow-Origin", "*")
+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ } else if origin := c.Request.Header.Get("Origin"); origin != "" && sameOrigin(origin, c.Request.Host) {
+ c.Header("Access-Control-Allow-Origin", origin)
+ c.Header("Vary", "Origin")
+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ }
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
@@ -216,3 +231,11 @@ func corsMiddleware() gin.HandlerFunc {
c.Next()
}
}
+
+func sameOrigin(origin string, host string) bool {
+ trimmed := strings.TrimSpace(origin)
+ if trimmed == "" {
+ return false
+ }
+ return strings.HasSuffix(trimmed, "://"+host)
+}
diff --git a/cmd/localforge/src/static/index.html b/cmd/localforge/src/static/index.html
index 61f0a71..7286652 100644
--- a/cmd/localforge/src/static/index.html
+++ b/cmd/localforge/src/static/index.html
@@ -16,7 +16,10 @@
✦
ThinkTwice
-
+
+
+
+
- ← Back to Chat
+
diff --git a/cmd/localforge/src/static/login.html b/cmd/localforge/src/static/login.html
new file mode 100644
index 0000000..c46c557
--- /dev/null
+++ b/cmd/localforge/src/static/login.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
Login
+
+
+
+
+
+
+
+
+ ✦
+ ThinkTwice Agent
+
+ Sign in
+ Use the localforge admin credentials configured in the environment.
+
+
+
+
+
+
+
+
+
diff --git a/cmd/localforge/src/static/settings.html b/cmd/localforge/src/static/settings.html
index 9a11b8d..671774a 100644
--- a/cmd/localforge/src/static/settings.html
+++ b/cmd/localforge/src/static/settings.html
@@ -25,6 +25,7 @@
.settings-nav-header {
display: flex;
align-items: center;
+ justify-content: space-between;
gap: 10px;
padding-bottom: 12px;
margin-bottom: 16px;
@@ -41,6 +42,13 @@
color: var(--text-primary);
}
+ .settings-nav-header-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ }
+
.settings-nav-title {
font-size: 15px;
font-weight: 600;
@@ -350,8 +358,11 @@