diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:22:30 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:22:30 +0000 |
| commit | 3962597950421e422b6e1ce57764550f5600ded6 (patch) | |
| tree | ef376c4c192293869fe408cb27abe6bba9e1fa32 | |
| parent | e8d1b80bd504088a7535e6045ab77f1ddd3b3d43 (diff) | |
Fix working_dir failures: validate path early, remove hardcoded /root
executor/claude.go: stat working_dir before cmd.Start() so a missing
or inaccessible directory surfaces as a clear error
("working_dir \"/bad/path\": no such file or directory") rather than
an opaque chdir failure wrapped in "starting claude".
api/elaborate.go: replace the hardcoded /root/workspace/claudomator
path with buildElaboratePrompt(workDir) which injects the server's
actual working directory (from os.Getwd() at startup). Empty workDir
tells the model to leave working_dir blank.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/api/elaborate.go | 14 | ||||
| -rw-r--r-- | internal/api/elaborate_test.go | 18 | ||||
| -rw-r--r-- | internal/api/server.go | 4 | ||||
| -rw-r--r-- | internal/executor/claude.go | 3 | ||||
| -rw-r--r-- | internal/executor/claude_test.go | 28 |
5 files changed, 62 insertions, 5 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 357bf3c..d275d61 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -13,12 +13,15 @@ import ( const elaborateTimeout = 30 * time.Second -const elaborateSystemPrompt = `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. +func buildElaboratePrompt(workDir string) string { + workDirLine := ` "working_dir": string — leave empty unless you have a specific reason to set it,` + if workDir != "" { + workDirLine = fmt.Sprintf(` "working_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir) + } + return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON. -If the request is about Claudomator itself (e.g. "add a feature", "fix a bug", "hide completed tasks"), treat it as a software engineering task to be implemented in the Claudomator codebase at /root/workspace/claudomator. - Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation): { @@ -27,7 +30,7 @@ Output ONLY a valid JSON object matching this schema (no markdown fences, no pro "claude": { "model": string — "sonnet" unless the task obviously needs opus, "instructions": string — detailed, step-by-step instructions for Claude, - "working_dir": string — use "/root/workspace/claudomator" for Claudomator tasks, empty string otherwise, +` + workDirLine + ` "max_budget_usd": number — conservative estimate (0.25–5.00), "allowed_tools": array — only tools the task genuinely needs }, @@ -35,6 +38,7 @@ Output ONLY a valid JSON object matching this schema (no markdown fences, no pro "priority": string — "normal" | "high" | "low", "tags": array — relevant lowercase tags }` +} // elaboratedTask mirrors the task creation schema for elaboration responses. type elaboratedTask struct { @@ -98,7 +102,7 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), "-p", input.Prompt, - "--system-prompt", elaborateSystemPrompt, + "--system-prompt", buildElaboratePrompt(s.workDir), "--output-format", "json", "--model", "haiku", ) diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go index ff158a8..52f7fdf 100644 --- a/internal/api/elaborate_test.go +++ b/internal/api/elaborate_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -28,6 +29,23 @@ func createFakeClaude(t *testing.T, output string, exitCode int) string { return script } +func TestElaboratePrompt_ContainsWorkDir(t *testing.T) { + prompt := buildElaboratePrompt("/some/custom/path") + if !strings.Contains(prompt, "/some/custom/path") { + t.Error("prompt should contain the provided workDir") + } + if strings.Contains(prompt, "/root/workspace/claudomator") { + t.Error("prompt should not hardcode /root/workspace/claudomator") + } +} + +func TestElaboratePrompt_EmptyWorkDir(t *testing.T) { + prompt := buildElaboratePrompt("") + if strings.Contains(prompt, "/root") { + t.Error("prompt should not reference /root when workDir is empty") + } +} + func TestElaborateTask_Success(t *testing.T) { srv, _ := testServer(t) diff --git a/internal/api/server.go b/internal/api/server.go index 315b64b..8415b28 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "os" "time" "github.com/thepeterstone/claudomator/internal/executor" @@ -25,9 +26,11 @@ type Server struct { mux *http.ServeMux claudeBinPath string // path to claude binary; defaults to "claude" elaborateCmdPath string // overrides claudeBinPath; used in tests + workDir string // working directory injected into elaborate system prompt } func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath string) *Server { + wd, _ := os.Getwd() s := &Server{ store: store, logStore: store, @@ -36,6 +39,7 @@ func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, clau logger: logger, mux: http.NewServeMux(), claudeBinPath: claudeBinPath, + workDir: wd, } s.routes() return s diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 8486427..7b3884c 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -40,6 +40,9 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi "CLAUDOMATOR_TASK_ID="+t.ID, ) if t.Claude.WorkingDir != "" { + if _, err := os.Stat(t.Claude.WorkingDir); err != nil { + return fmt.Errorf("working_dir %q: %w", t.Claude.WorkingDir, err) + } cmd.Dir = t.Claude.WorkingDir } diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index fa81b09..ac6aabf 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -1,9 +1,13 @@ package executor import ( + "context" + "io" + "log/slog" "strings" "testing" + "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) @@ -166,6 +170,30 @@ func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) { } } +func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { + r := &ClaudeRunner{ + BinaryPath: "true", // would succeed if it ran + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + LogDir: t.TempDir(), + } + tk := &task.Task{ + Claude: task.ClaudeConfig{ + WorkingDir: "/nonexistent/path/does/not/exist", + SkipPlanning: true, + }, + } + exec := &storage.Execution{ID: "test-exec"} + + err := r.Run(context.Background(), tk, exec) + + if err == nil { + t.Fatal("expected error for inaccessible working_dir, got nil") + } + if !strings.Contains(err.Error(), "working_dir") { + t.Errorf("expected 'working_dir' in error, got: %v", err) + } +} + func TestClaudeRunner_BinaryPath_Default(t *testing.T) { r := &ClaudeRunner{} if r.binaryPath() != "claude" { |
