From 83f015cb955545d071f88c30a54f4ac1553cc709 Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Fri, 12 Jun 2026 01:54:08 +0200 Subject: [PATCH] feat: add agent user nodes with access token authentication --- src/cmd/add.go | 15 ++++++ src/cmd/serve.go | 10 +++- src/cmd/update.go | 9 +++- src/serve/oidc.go | 6 ++- src/serve/server.go | 14 +++-- src/service/api_client.go | 6 +++ src/service/node_service.go | 13 ++--- src/service/node_service_impl.go | 90 +++++++++++++++++++++++++++++--- 8 files changed, 143 insertions(+), 20 deletions(-) diff --git a/src/cmd/add.go b/src/cmd/add.go index 2dd6854..e41bba2 100644 --- a/src/cmd/add.go +++ b/src/cmd/add.go @@ -3,7 +3,9 @@ package cmd import ( "axolotl/models" "axolotl/service" + "encoding/json" "fmt" + "io" "os" "github.com/spf13/cobra" @@ -64,6 +66,9 @@ var addCmd = &cobra.Command{ return } + if n.GetProperty("type") == "agent" { + printAgentToken(cmd.OutOrStdout(), n) + } PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } @@ -81,3 +86,13 @@ func init() { f.StringArrayVar(&cTags, "tag", nil, "label tag (alias for --rel tagname)") f.StringArrayVar(&cRels, "rel", nil, "relation (prefix::value or relname:target)") } + +func printAgentToken(w io.Writer, n *models.Node) { + var c struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal([]byte(n.Content), &c); err == nil && c.AccessToken != "" { + fmt.Fprintf(w, "\nAgent access token: %s\n", c.AccessToken) + fmt.Fprintln(w, "Save this token — it cannot be retrieved later via the CLI.") + } +} diff --git a/src/cmd/serve.go b/src/cmd/serve.go index 88ec5b4..7003793 100644 --- a/src/cmd/serve.go +++ b/src/cmd/serve.go @@ -23,6 +23,14 @@ var serveCmd = &cobra.Command{ oidcCfg = &cfg.OIDC } + agentLookup := func(token string) string { + st, err := store.FindOrInitSQLiteStore() + if err != nil { + return "" + } + return service.LookupAgentToken(st, token) + } + handler, err := serve.New(func(user string) (service.NodeService, error) { if user == "" { return nil, fmt.Errorf("user is required") @@ -32,7 +40,7 @@ var serveCmd = &cobra.Command{ return nil, err } return service.NewLocalNodeService(st, user), nil - }, oidcCfg) + }, oidcCfg, agentLookup) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/src/cmd/update.go b/src/cmd/update.go index d8e32f7..a1f34e6 100644 --- a/src/cmd/update.go +++ b/src/cmd/update.go @@ -11,7 +11,7 @@ import ( var ( uTitle, uContent, uDue string - uClearDue bool + uClearDue, uRegenToken bool uStatus, uPrio, uType string uNamespace, uAssignee string uAddTags, uRmTags, uAddRels, uRmRels []string @@ -83,12 +83,18 @@ var updateCmd = &cobra.Command{ } input.RemoveRels = append(input.RemoveRels, ri) } + if uRegenToken { + input.RegenerateAccessToken = true + } n, err := svc.Update(args[0], input) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + if uRegenToken { + printAgentToken(cmd.OutOrStdout(), n) + } PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } @@ -109,4 +115,5 @@ func init() { f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove label tag") f.StringArrayVar(&uAddRels, "rel", nil, "add relation (prefix::value or relname:target)") f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relation (prefix::value or relname:target)") + f.BoolVar(&uRegenToken, "regenerate-access-token", false, "regenerate agent access token") } diff --git a/src/serve/oidc.go b/src/serve/oidc.go index b55cd41..7c60f24 100644 --- a/src/serve/oidc.go +++ b/src/serve/oidc.go @@ -13,7 +13,8 @@ const userContextKey contextKey = "ax_user" // withSessionAuth wraps a handler with ax session token authentication. // Auth endpoints (/auth/*) are passed through without a token check. // All other requests must supply Authorization: Bearer . -func withSessionAuth(ah *authHandler, next http.Handler) http.Handler { +// If the token is not a valid OIDC session, agentLookup is tried as a fallback. +func withSessionAuth(ah *authHandler, agentLookup func(string) string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/auth/") { next.ServeHTTP(w, r) @@ -26,6 +27,9 @@ func withSessionAuth(ah *authHandler, next http.Handler) http.Handler { } token := strings.TrimPrefix(auth, "Bearer ") username := ah.lookupSession(token) + if username == "" && agentLookup != nil { + username = agentLookup(token) + } if username == "" { writeError(w, http.StatusUnauthorized, "invalid or expired session; run 'ax login'") return diff --git a/src/serve/server.go b/src/serve/server.go index d176089..c92f979 100644 --- a/src/serve/server.go +++ b/src/serve/server.go @@ -14,8 +14,8 @@ import ( // When oidcCfg is non-nil, every request must carry a valid Bearer token; // the authenticated username is derived from the token claim configured in // OIDCConfig.UserClaim. Without OIDC, the X-Ax-User header is used instead. -func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.OIDCConfig) (http.Handler, error) { - s := &server{newSvc: newSvc} +func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.OIDCConfig, agentLookup func(string) string) (http.Handler, error) { + s := &server{newSvc: newSvc, agentLookup: agentLookup} mux := http.NewServeMux() mux.HandleFunc("GET /nodes", s.listNodes) mux.HandleFunc("POST /nodes", s.addNode) @@ -35,17 +35,23 @@ func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.O mux.HandleFunc("POST /auth/device/start", ah.deviceStart) mux.HandleFunc("GET /auth/callback", ah.callback) mux.HandleFunc("GET /auth/poll", ah.poll) - return withRateLimit(rl, withSessionAuth(ah, mux)), nil + return withRateLimit(rl, withSessionAuth(ah, agentLookup, mux)), nil } return withRateLimit(rl, mux), nil } type server struct { - newSvc func(user string) (service.NodeService, error) + newSvc func(user string) (service.NodeService, error) + agentLookup func(string) string } func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) { user := userFromContext(r) + if user == "" && s.agentLookup != nil { + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + user = s.agentLookup(strings.TrimPrefix(auth, "Bearer ")) + } + } if user == "" { user = r.Header.Get("X-Ax-User") } diff --git a/src/service/api_client.go b/src/service/api_client.go index 9c5f775..cbf4213 100644 --- a/src/service/api_client.go +++ b/src/service/api_client.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strconv" ) @@ -42,6 +43,11 @@ func (c *apiClient) do(method, path string, body any) (*http.Response, error) { // setAuth attaches either a Bearer token (when a session exists) or the // X-Ax-User header (no session / non-OIDC servers). func (c *apiClient) setAuth(req *http.Request) error { + // Agent token takes priority (stateless, no login needed). + if token := os.Getenv("AX_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + return nil + } sess, err := store.LoadSession() if err != nil || sess == nil || sess.Token == "" { req.Header.Set("X-Ax-User", c.user) diff --git a/src/service/node_service.go b/src/service/node_service.go index 20347df..6e91648 100644 --- a/src/service/node_service.go +++ b/src/service/node_service.go @@ -47,12 +47,13 @@ type AddInput struct { // Adding an assignee rel replaces the previous single target. // Setting Namespace transfers ownership from the current namespace to the new one. type UpdateInput struct { - Title *string - Content *string - DueDate *string // nil = no change; pointer to "" = clear due date - Namespace *string // nil = no change; namespace name or ID to move node into - AddRels []RelInput - RemoveRels []RelInput + Title *string + Content *string + DueDate *string // nil = no change; pointer to "" = clear due date + Namespace *string // nil = no change; namespace name or ID to move node into + AddRels []RelInput + RemoveRels []RelInput + RegenerateAccessToken bool // when true, regenerates the access token for agent nodes } // ListFilter specifies which nodes to return. Empty slices are ignored. diff --git a/src/service/node_service_impl.go b/src/service/node_service_impl.go index ec11763..18f3cc9 100644 --- a/src/service/node_service_impl.go +++ b/src/service/node_service_impl.go @@ -3,6 +3,10 @@ package service import ( "axolotl/models" "axolotl/store" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" "fmt" "maps" "regexp" @@ -11,6 +15,39 @@ import ( "time" ) +// agentContent is the JSON structure stored in agent node content. +type agentContent struct { + AccessToken string `json:"access_token"` +} + +// generateAccessToken returns a cryptographically random base64url token. +func generateAccessToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// LookupAgentToken finds the agent node whose content contains the given +// access token. Returns the agent node ID, or "" if not found. +func LookupAgentToken(st store.GraphStore, token string) string { + agents, err := st.FindNodes([]*models.Rel{{Type: "_type::agent", Target: ""}}) + if err != nil { + return "" + } + for _, agent := range agents { + var c agentContent + if err := json.Unmarshal([]byte(agent.Content), &c); err != nil { + continue + } + if subtle.ConstantTimeCompare([]byte(c.AccessToken), []byte(token)) == 1 { + return agent.ID + } + } + return "" +} + type nodeServiceImpl struct { store store.GraphStore userID string @@ -105,9 +142,16 @@ func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeI // If the user node doesn't exist yet, returns an empty permContext (no access); // Add operations still work because unresolved targets skip the permission check. func (s *nodeServiceImpl) getPermContext() (*permContext, error) { - userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user") - if err != nil { - return nil, err + // If userID is already a node ID (e.g. for agents), use it directly. + var userNodeID string + if exists, _ := s.store.NodeExists(s.userID); exists { + userNodeID = s.userID + } else { + var err error + userNodeID, err = s.resolveIDByNameAndType(s.store, s.userID, "user") + if err != nil { + return nil, err + } } pc := &permContext{levels: make(map[string]int)} if userNodeID == "" { @@ -158,7 +202,7 @@ func (s *nodeServiceImpl) getPermContext() (*permContext, error) { // --- Validation --- var ( - validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true} + validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true, "agent": true} validStatuses = map[string]bool{"open": true, "done": true} validPrios = map[string]bool{"high": true, "medium": true, "low": true} ) @@ -349,6 +393,16 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { } } + // Agent nodes get an auto-generated access token stored as JSON content. + if tmp.GetProperty("type") == "agent" { + token, err := generateAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + data, _ := json.Marshal(agentContent{AccessToken: token}) + input.Content = string(data) + } + dueDate, err := parseDueDate(input.DueDate) if err != nil { return nil, err @@ -419,8 +473,15 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { } } + // Agent nodes get self-ownership (like users). + if tmp.GetProperty("type") == "agent" { + if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil { + return err + } + } + // Grant ownership of the new node. - // Namespace nodes are owned by their creator (user node). + // Namespace and agent nodes are owned by their creator (user node). // All other nodes are owned by the namespace they belong to — the user // retains transitive ownership through the namespace's own ownership chain // (e.g. user→has_ownership→default-ns→has_ownership→node). @@ -429,7 +490,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { return err } ownerID := creatorID - if tmp.GetProperty("type") != "namespace" { + nodeType := tmp.GetProperty("type") + if nodeType != "namespace" && nodeType != "agent" { nsRef := input.Namespace if nsRef == "" { nsRef = s.userID @@ -465,7 +527,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } // Field/tag changes, rel removals, and namespace change require can_write on the node. - needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil + needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil || input.RegenerateAccessToken for _, ri := range input.AddRels { if ri.Target == "" { needsWrite = true @@ -548,8 +610,22 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er title = *input.Title } if input.Content != nil { + if current.GetProperty("type") == "agent" { + return fmt.Errorf("cannot set content on agent nodes; use --regenerate-access-token to rotate the token") + } content = *input.Content } + if input.RegenerateAccessToken { + if current.GetProperty("type") != "agent" { + return fmt.Errorf("cannot regenerate access token: node is not an agent") + } + token, err := generateAccessToken() + if err != nil { + return fmt.Errorf("failed to generate access token: %w", err) + } + data, _ := json.Marshal(agentContent{AccessToken: token}) + content = string(data) + } if input.DueDate != nil { parsed, err := parseDueDate(*input.DueDate) if err != nil {