Merge pull request 'dev' (#11) from dev into main
Some checks failed
CI / test (push) Failing after 3s

Reviewed-on: #11
This commit is contained in:
Snider 2026-03-24 10:34:37 +00:00
commit a0cd2d725d
12 changed files with 329 additions and 130 deletions

View file

@ -61,24 +61,23 @@ func AddMCPCommands(root *cli.Command) {
}
func runServe() error {
// Build MCP service options
var opts []mcp.Option
opts := mcp.Options{}
if workspaceFlag != "" {
opts = append(opts, mcp.WithWorkspaceRoot(workspaceFlag))
opts.WorkspaceRoot = workspaceFlag
} else {
// Explicitly unrestricted when no workspace specified
opts = append(opts, mcp.WithWorkspaceRoot(""))
opts.Unrestricted = true
}
// Register OpenBrain subsystem (direct HTTP to api.lthn.sh)
opts = append(opts, mcp.WithSubsystem(brain.NewDirect()))
// Register agentic subsystem (workspace prep, agent orchestration)
opts = append(opts, mcp.WithSubsystem(agentic.NewPrep()))
// Register OpenBrain and agentic subsystems
opts.Subsystems = []mcp.Subsystem{
brain.NewDirect(),
agentic.NewPrep(),
}
// Create the MCP service
svc, err := mcp.New(opts...)
svc, err := mcp.New(opts)
if err != nil {
return cli.Wrap(err, "create MCP service")
}

3
go.mod
View file

@ -3,9 +3,9 @@ module forge.lthn.ai/core/mcp
go 1.26.0
require (
dappco.re/go/core v0.4.7
forge.lthn.ai/core/api v0.1.5
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-ai v0.1.12
forge.lthn.ai/core/go-io v0.1.7
forge.lthn.ai/core/go-log v0.0.4
@ -21,7 +21,6 @@ require (
)
require (
forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect

2
go.sum
View file

@ -1,5 +1,3 @@
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=

View file

@ -155,7 +155,19 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
}, nil
}
// Step 3: Spawn agent as a detached process
// Step 3: Write status BEFORE spawning so concurrent dispatches
// see this workspace as "running" during the concurrency check.
writeStatus(wsDir, &WorkspaceStatus{
Status: "running",
Agent: input.Agent,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
StartedAt: time.Now(),
Runs: 1,
})
// Step 4: Spawn agent as a detached process
// Uses Setpgid so the agent survives parent (MCP server) death.
// Output goes directly to log file (not buffered in memory).
command, args, err := agentCommand(input.Agent, prompt)
@ -191,12 +203,19 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
if err := cmd.Start(); err != nil {
outFile.Close()
// Revert status so the slot is freed
writeStatus(wsDir, &WorkspaceStatus{
Status: "failed",
Agent: input.Agent,
Repo: input.Repo,
Task: input.Task,
})
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to spawn "+input.Agent, err)
}
pid := cmd.Process.Pid
// Write initial status
// Update status with PID now that agent is running
writeStatus(wsDir, &WorkspaceStatus{
Status: "running",
Agent: input.Agent,
@ -237,3 +256,4 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
OutputFile: outputFile,
}, nil
}

View file

@ -312,7 +312,7 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
// --- Helpers ---
func (s *PrepSubsystem) plansDir() string {
return filepath.Join(s.codePath, "host-uk", "core", ".core", "plans")
return filepath.Join(s.codePath, ".core", "plans")
}
func planPath(dir, id string) string {

View file

@ -25,13 +25,13 @@ import (
// PrepSubsystem provides agentic MCP tools.
type PrepSubsystem struct {
forgeURL string
forgeToken string
brainURL string
brainKey string
specsPath string
codePath string
client *http.Client
forgeURL string
forgeToken string
brainURL string
brainKey string
specsPath string
codePath string
client *http.Client
}
// NewPrep creates an agentic subsystem.
@ -51,13 +51,13 @@ func NewPrep() *PrepSubsystem {
}
return &PrepSubsystem{
forgeURL: envOr("FORGE_URL", "https://forge.lthn.ai"),
forgeToken: forgeToken,
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
brainKey: brainKey,
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
client: &http.Client{Timeout: 30 * time.Second},
forgeURL: envOr("FORGE_URL", "https://forge.lthn.ai"),
forgeToken: forgeToken,
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
brainKey: brainKey,
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
client: &http.Client{Timeout: 30 * time.Second},
}
}
@ -68,6 +68,42 @@ func envOr(key, fallback string) string {
return fallback
}
func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) {
if strings.TrimSpace(value) != value {
return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil)
}
if value == "" {
return "", nil
}
if strings.Contains(value, "\\") {
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
}
parts := strings.Split(value, "/")
if !allowSubdirs && len(parts) != 1 {
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
}
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return "", coreerr.E("prepWorkspace", field+" contains invalid path segment", nil)
}
for _, r := range part {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-' || r == '_' || r == '.':
continue
default:
return "", coreerr.E("prepWorkspace", field+" contains invalid characters", nil)
}
}
}
return value, nil
}
// Name implements mcp.Subsystem.
func (s *PrepSubsystem) Name() string { return "agentic" }
@ -98,7 +134,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
// workspaceRoot returns the base directory for agent workspaces.
func (s *PrepSubsystem) workspaceRoot() string {
return filepath.Join(s.codePath, "host-uk", "core", ".core", "workspace")
return filepath.Join(s.codePath, ".core", "workspace")
}
// --- Input/Output types ---
@ -117,20 +153,41 @@ type PrepInput struct {
// PrepOutput is the output for agentic_prep_workspace.
type PrepOutput struct {
Success bool `json:"success"`
WorkspaceDir string `json:"workspace_dir"`
WikiPages int `json:"wiki_pages"`
SpecFiles int `json:"spec_files"`
Memories int `json:"memories"`
Consumers int `json:"consumers"`
ClaudeMd bool `json:"claude_md"`
GitLog int `json:"git_log_entries"`
Success bool `json:"success"`
WorkspaceDir string `json:"workspace_dir"`
WikiPages int `json:"wiki_pages"`
SpecFiles int `json:"spec_files"`
Memories int `json:"memories"`
Consumers int `json:"consumers"`
ClaudeMd bool `json:"claude_md"`
GitLog int `json:"git_log_entries"`
}
func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) {
if input.Repo == "" {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "repo is required", nil)
}
repo, err := sanitizeRepoPathSegment(input.Repo, "repo", false)
if err != nil {
return nil, PrepOutput{}, err
}
input.Repo = repo
planTemplate, err := sanitizeRepoPathSegment(input.PlanTemplate, "plan_template", false)
if err != nil {
return nil, PrepOutput{}, err
}
input.PlanTemplate = planTemplate
persona := input.Persona
if persona != "" {
persona, err = sanitizeRepoPathSegment(persona, "persona", true)
if err != nil {
return nil, PrepOutput{}, err
}
}
if input.Org == "" {
input.Org = "core"
}
@ -154,7 +211,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// 1. Clone repo into src/ and create feature branch
srcDir := filepath.Join(wsDir, "src")
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
cloneCmd.Run()
if err := cloneCmd.Run(); err != nil {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
}
// Create feature branch
taskSlug := strings.Map(func(r rune) rune {
@ -170,11 +229,14 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
taskSlug = taskSlug[:40]
}
taskSlug = strings.Trim(taskSlug, "-")
branchName := fmt.Sprintf("agent/%s", taskSlug)
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
branchCmd.Dir = srcDir
branchCmd.Run()
if taskSlug != "" {
branchName := fmt.Sprintf("agent/%s", taskSlug)
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
branchCmd.Dir = srcDir
if err := branchCmd.Run(); err != nil {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to create branch", err)
}
}
// Create context dirs inside src/
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
@ -196,8 +258,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
}
// Copy persona if specified
if input.Persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", input.Persona+".md")
if persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
if data, err := coreio.Local.Read(personaPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
}
@ -338,9 +400,9 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
Description string `yaml:"description"`
Guidelines []string `yaml:"guidelines"`
Phases []struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tasks []any `yaml:"tasks"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Tasks []any `yaml:"tasks"`
} `yaml:"phases"`
}

View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"strings"
"testing"
)
func TestSanitizeRepoPathSegment_Good(t *testing.T) {
t.Run("repo", func(t *testing.T) {
value, err := sanitizeRepoPathSegment("go-io", "repo", false)
if err != nil {
t.Fatalf("expected valid repo name, got error: %v", err)
}
if value != "go-io" {
t.Fatalf("expected normalized value, got: %q", value)
}
})
t.Run("persona", func(t *testing.T) {
value, err := sanitizeRepoPathSegment("engineering/backend-architect", "persona", true)
if err != nil {
t.Fatalf("expected valid persona path, got error: %v", err)
}
if value != "engineering/backend-architect" {
t.Fatalf("expected persona path, got: %q", value)
}
})
}
func TestSanitizeRepoPathSegment_Bad(t *testing.T) {
cases := []struct {
name string
value string
allowPath bool
}{
{"repo segment traversal", "../repo", false},
{"repo nested path", "team/repo", false},
{"plan template traversal", "../secret", false},
{"persona traversal", "engineering/../../admin", true},
{"backslash", "org\\repo", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := sanitizeRepoPathSegment(tc.value, tc.name, tc.allowPath)
if err == nil {
t.Fatal("expected error")
}
})
}
}
func TestPrepWorkspace_Bad_BadRepoTraversal(t *testing.T) {
s := &PrepSubsystem{codePath: t.TempDir()}
_, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{Repo: "../repo"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "repo") {
t.Fatalf("expected repo error, got %q", err)
}
}
func TestPrepWorkspace_Bad_BadPersonaTraversal(t *testing.T) {
s := &PrepSubsystem{codePath: t.TempDir()}
_, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{
Repo: "repo",
Persona: "engineering/../../admin",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "persona") {
t.Fatalf("expected persona error, got %q", err)
}
}
func TestPrepWorkspace_Bad_BadPlanTemplateTraversal(t *testing.T) {
s := &PrepSubsystem{codePath: t.TempDir()}
_, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{
Repo: "repo",
PlanTemplate: "../secret",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "plan_template") {
t.Fatalf("expected plan template error, got %q", err)
}
}

View file

@ -43,9 +43,7 @@ type AgentsConfig struct {
// loadAgentsConfig reads config/agents.yaml from the code path.
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
paths := []string{
filepath.Join(s.codePath, "core", "agent", "config", "agents.yaml"),
filepath.Join(s.codePath, "core", "agent", ".core", "agents.yaml"),
filepath.Join(s.codePath, "host-uk", "core", ".core", "agents.yaml"),
filepath.Join(s.codePath, ".core", "agents.yaml"),
}
for _, path := range paths {
@ -103,31 +101,55 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
return time.Duration(rate.SustainedDelay) * time.Second
}
// countRunningByAgent counts running workspaces for a specific agent type.
func (s *PrepSubsystem) countRunningByAgent(agent string) int {
// listWorkspaceDirs returns all workspace directories, including those
// nested one level deep (e.g. workspace/core/go-io-123/).
func (s *PrepSubsystem) listWorkspaceDirs() []string {
wsRoot := s.workspaceRoot()
entries, err := coreio.Local.List(wsRoot)
if err != nil {
return 0
return nil
}
count := 0
var dirs []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(wsRoot, entry.Name())
// Check if this dir has a status.json (it's a workspace)
if coreio.Local.IsFile(filepath.Join(path, "status.json")) {
dirs = append(dirs, path)
continue
}
// Otherwise check one level deeper (org subdirectory)
subEntries, err := coreio.Local.List(path)
if err != nil {
continue
}
for _, sub := range subEntries {
if sub.IsDir() {
subPath := filepath.Join(path, sub.Name())
if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) {
dirs = append(dirs, subPath)
}
}
}
}
return dirs
}
st, err := readStatus(filepath.Join(wsRoot, entry.Name()))
// countRunningByAgent counts running workspaces for a specific agent type.
func (s *PrepSubsystem) countRunningByAgent(agent string) int {
count := 0
for _, wsDir := range s.listWorkspaceDirs() {
st, err := readStatus(wsDir)
if err != nil || st.Status != "running" {
continue
}
// Match on base agent type (gemini:flash matches gemini)
stBase := strings.SplitN(st.Agent, ":", 2)[0]
if stBase != agent {
continue
}
if st.PID > 0 {
proc, err := os.FindProcess(st.PID)
if err == nil && proc.Signal(syscall.Signal(0)) == nil {
@ -135,7 +157,6 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
}
}
}
return count
}
@ -163,19 +184,7 @@ func (s *PrepSubsystem) canDispatch() bool {
// drainQueue finds the oldest queued workspace and spawns it if a slot is available.
// Applies rate-based delay between spawns.
func (s *PrepSubsystem) drainQueue() {
wsRoot := s.workspaceRoot()
entries, err := coreio.Local.List(wsRoot)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
wsDir := filepath.Join(wsRoot, entry.Name())
for _, wsDir := range s.listWorkspaceDirs() {
st, err := readStatus(wsDir)
if err != nil || st.Status != "queued" {
continue

View file

@ -29,19 +29,19 @@ import (
// WorkspaceStatus represents the current state of an agent workspace.
type WorkspaceStatus struct {
Status string `json:"status"` // running, completed, blocked, failed
Agent string `json:"agent"` // gemini, claude, codex
Repo string `json:"repo"` // target repo
Org string `json:"org,omitempty"` // forge org (e.g. "core")
Task string `json:"task"` // task description
Branch string `json:"branch,omitempty"` // git branch name
Issue int `json:"issue,omitempty"` // forge issue number
PID int `json:"pid,omitempty"` // process ID (if running)
StartedAt time.Time `json:"started_at"` // when dispatch started
UpdatedAt time.Time `json:"updated_at"` // last status change
Question string `json:"question,omitempty"` // from BLOCKED.md
Runs int `json:"runs"` // how many times dispatched/resumed
PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created)
Status string `json:"status"` // running, completed, blocked, failed
Agent string `json:"agent"` // gemini, claude, codex
Repo string `json:"repo"` // target repo
Org string `json:"org,omitempty"` // forge org (e.g. "core")
Task string `json:"task"` // task description
Branch string `json:"branch,omitempty"` // git branch name
Issue int `json:"issue,omitempty"` // forge issue number
PID int `json:"pid,omitempty"` // process ID (if running)
StartedAt time.Time `json:"started_at"` // when dispatch started
UpdatedAt time.Time `json:"updated_at"` // last status change
Question string `json:"question,omitempty"` // from BLOCKED.md
Runs int `json:"runs"` // how many times dispatched/resumed
PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created)
}
func writeStatus(wsDir string, status *WorkspaceStatus) error {
@ -95,28 +95,21 @@ func (s *PrepSubsystem) registerStatusTool(server *mcp.Server) {
}
func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, input StatusInput) (*mcp.CallToolResult, StatusOutput, error) {
wsRoot := s.workspaceRoot()
entries, err := coreio.Local.List(wsRoot)
if err != nil {
return nil, StatusOutput{}, coreerr.E("status", "no workspaces found", err)
wsDirs := s.listWorkspaceDirs()
if len(wsDirs) == 0 {
return nil, StatusOutput{}, coreerr.E("status", "no workspaces found", nil)
}
var workspaces []WorkspaceInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
for _, wsDir := range wsDirs {
name := filepath.Base(wsDir)
// Filter by specific workspace if requested
if input.Workspace != "" && name != input.Workspace {
continue
}
wsDir := filepath.Join(wsRoot, name)
info := WorkspaceInfo{Name: name}
// Try reading status.json
@ -129,8 +122,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
} else {
info.Status = "unknown"
}
fi, _ := entry.Info()
if fi != nil {
if fi, err := os.Stat(wsDir); err == nil {
info.Age = time.Since(fi.ModTime()).Truncate(time.Minute).String()
}
workspaces = append(workspaces, info)

View file

@ -6,36 +6,34 @@ import (
"testing"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/go-process"
core "forge.lthn.ai/core/go/pkg/core"
)
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests.
func newTestProcessService(t *testing.T) *process.Service {
t.Helper()
c := core.New()
raw, err := process.NewService(process.Options{})(c)
c, err := core.New(
core.WithName("process", process.NewService(process.Options{})),
)
if err != nil {
t.Fatalf("Failed to create process service: %v", err)
}
svc := raw.(*process.Service)
resultFrom := func(err error) core.Result {
if err != nil {
return core.Result{Value: err}
}
return core.Result{OK: true}
svc, err := core.ServiceFor[*process.Service](c, "process")
if err != nil {
t.Fatalf("Failed to get process service: %v", err)
}
c.Service("process", core.Service{
OnStart: func() core.Result { return resultFrom(svc.OnStartup(context.Background())) },
OnStop: func() core.Result { return resultFrom(svc.OnShutdown(context.Background())) },
if err := svc.OnStartup(context.Background()); err != nil {
t.Fatalf("Failed to start process service: %v", err)
}
t.Cleanup(func() {
_ = svc.OnShutdown(context.Background())
core.ClearInstance()
})
if r := c.ServiceStartup(context.Background(), nil); !r.OK {
t.Fatalf("Failed to start core: %v", r.Value)
}
t.Cleanup(func() { c.ServiceShutdown(context.Background()) })
return svc
}

View file

@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"strings"
"time"
coreerr "forge.lthn.ai/core/go-log"
@ -81,18 +82,27 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
}
// withAuth wraps an http.Handler with Bearer token authentication.
// If token is empty, authentication is disabled (passthrough).
// If token is empty, requests are rejected.
func withAuth(token string, next http.Handler) http.Handler {
if token == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(token) == "" {
w.Header().Set("WWW-Authenticate", `Bearer`)
http.Error(w, `{"error":"authentication not configured"}`, http.StatusUnauthorized)
return
}
auth := r.Header.Get("Authorization")
if len(auth) < 7 || auth[:7] != "Bearer " {
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
return
}
provided := auth[7:]
provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
if len(provided) == 0 {
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
return
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return

View file

@ -157,19 +157,35 @@ func TestWithAuth_Bad_MissingToken(t *testing.T) {
}
}
func TestWithAuth_Good_EmptyTokenPassthrough(t *testing.T) {
func TestWithAuth_Bad_EmptyConfiguredToken(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
// Empty token disables auth
// Empty token now requires explicit configuration
wrapped := withAuth("", handler)
req, _ := http.NewRequest("GET", "/", nil)
rr := &fakeResponseWriter{code: 200}
wrapped.ServeHTTP(rr, req)
if rr.code != 200 {
t.Errorf("expected 200 with auth disabled, got %d", rr.code)
if rr.code != 401 {
t.Errorf("expected 401 with empty configured token, got %d", rr.code)
}
}
func TestWithAuth_Bad_NonBearerToken(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
wrapped := withAuth("my-token", handler)
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Token my-token")
rr := &fakeResponseWriter{code: 200}
wrapped.ServeHTTP(rr, req)
if rr.code != 401 {
t.Errorf("expected 401 with non-Bearer auth, got %d", rr.code)
}
}
@ -231,4 +247,4 @@ func (f *fakeResponseWriter) Header() http.Header {
}
func (f *fakeResponseWriter) Write(b []byte) (int, error) { return len(b), nil }
func (f *fakeResponseWriter) WriteHeader(code int) { f.code = code }
func (f *fakeResponseWriter) WriteHeader(code int) { f.code = code }