Merge pull request 'dev' (#11) from dev into main
Some checks failed
CI / test (push) Failing after 3s
Some checks failed
CI / test (push) Failing after 3s
Reviewed-on: #11
This commit is contained in:
commit
a0cd2d725d
12 changed files with 329 additions and 130 deletions
|
|
@ -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
3
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
96
pkg/mcp/agentic/prep_test.go
Normal file
96
pkg/mcp/agentic/prep_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue