summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-03 21:22:30 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-03 21:22:30 +0000
commit3962597950421e422b6e1ce57764550f5600ded6 (patch)
treeef376c4c192293869fe408cb27abe6bba9e1fa32
parente8d1b80bd504088a7535e6045ab77f1ddd3b3d43 (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.go14
-rw-r--r--internal/api/elaborate_test.go18
-rw-r--r--internal/api/server.go4
-rw-r--r--internal/executor/claude.go3
-rw-r--r--internal/executor/claude_test.go28
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" {