diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 38f98a867d..52e6264d9f 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1026,6 +1026,15 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo CustomArgs: customArgs, McpConfig: mcpConfig, } + // openclaw loads its bootstrap files (AGENTS.md, SOUL.md, ...) from its own + // workspace dir rather than the task workdir, so the AGENTS.md written by + // execenv.InjectRuntimeConfig is never read. Pass agent instructions inline + // via SystemPrompt so the backend can prepend them to the --message payload. + // Other providers already surface instructions through their runtime config + // file and don't need this. + if provider == "openclaw" { + execOpts.SystemPrompt = instructions + } result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID) if err != nil { diff --git a/server/pkg/agent/openclaw.go b/server/pkg/agent/openclaw.go index 485e0ee961..77e780e51b 100644 --- a/server/pkg/agent/openclaw.go +++ b/server/pkg/agent/openclaw.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os/exec" "strings" "time" @@ -14,10 +15,12 @@ import ( // openclawBlockedArgs are flags hardcoded by the daemon that must not be // overridden by user-configured custom_args. var openclawBlockedArgs = map[string]blockedArgMode{ - "--local": blockedStandalone, // local mode for daemon execution - "--json": blockedStandalone, // JSON output for daemon communication - "--session-id": blockedWithValue, // managed by daemon for session resumption - "--message": blockedWithValue, // prompt is set by daemon + "--local": blockedStandalone, // local mode for daemon execution + "--json": blockedStandalone, // JSON output for daemon communication + "--session-id": blockedWithValue, // managed by daemon for session resumption + "--message": blockedWithValue, // prompt is set by daemon + "--model": blockedWithValue, // openclaw agent does not accept --model; model is bound at registration via `openclaw agents add/update --model` + "--system-prompt": blockedWithValue, // openclaw agent does not accept --system-prompt; instructions are injected into --message } // openclawBackend implements Backend by spawning `openclaw agent --message @@ -46,18 +49,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO if sessionID == "" { sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano()) } - args := []string{"agent", "--local", "--json", "--session-id", sessionID} - if opts.Model != "" { - args = append(args, "--model", opts.Model) - } - if opts.SystemPrompt != "" { - args = append(args, "--system-prompt", opts.SystemPrompt) - } - if opts.Timeout > 0 { - args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds()))) - } - args = append(args, filterCustomArgs(opts.CustomArgs, openclawBlockedArgs, b.cfg.Logger)...) - args = append(args, "--message", prompt) + args := buildOpenclawArgs(prompt, sessionID, opts, b.cfg.Logger) cmd := exec.CommandContext(runCtx, execPath, args...) b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args) @@ -141,6 +133,28 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO return &Session{Messages: msgCh, Result: resCh}, nil } +// buildOpenclawArgs assembles the argv for a one-shot `openclaw agent` invocation. +// +// The CLI only accepts --local, --json, --session-id, --timeout, --message (and +// flags like --agent / --channel that users pass through CustomArgs). Notably +// it does NOT accept --model or --system-prompt — model is bound at agent +// registration time via `openclaw agents add/update --model`, and instructions +// must be injected inline into --message because openclaw loads AGENTS.md from +// its own workspace directory, not from cwd. +func buildOpenclawArgs(prompt, sessionID string, opts ExecOptions, logger *slog.Logger) []string { + args := []string{"agent", "--local", "--json", "--session-id", sessionID} + if opts.Timeout > 0 { + args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds()))) + } + args = append(args, filterCustomArgs(opts.CustomArgs, openclawBlockedArgs, logger)...) + + if opts.SystemPrompt != "" { + prompt = opts.SystemPrompt + "\n\n" + prompt + } + args = append(args, "--message", prompt) + return args +} + // ── Event handlers ── // openclawEventResult holds accumulated state from processing the event stream. diff --git a/server/pkg/agent/openclaw_test.go b/server/pkg/agent/openclaw_test.go index d6ac5eaa09..0f0c991709 100644 --- a/server/pkg/agent/openclaw_test.go +++ b/server/pkg/agent/openclaw_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" "testing" + "time" ) func TestNewReturnsOpenclawBackend(t *testing.T) { @@ -913,3 +914,141 @@ func TestOpenclawInt64Nil(t *testing.T) { t.Errorf("got %d, want 0", got) } } + +// ── buildOpenclawArgs tests ── + +// indexOf returns the first index of s in args, or -1 if absent. +func indexOf(args []string, s string) int { + for i, a := range args { + if a == s { + return i + } + } + return -1 +} + +func TestBuildOpenclawArgsMinimal(t *testing.T) { + t.Parallel() + + args := buildOpenclawArgs("do work", "ses-1", ExecOptions{}, slog.Default()) + expected := []string{"agent", "--local", "--json", "--session-id", "ses-1", "--message", "do work"} + + if len(args) != len(expected) { + t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) + } + for i, want := range expected { + if args[i] != want { + t.Errorf("args[%d] = %q, want %q", i, args[i], want) + } + } +} + +func TestBuildOpenclawArgsDoesNotForwardModelOrSystemPrompt(t *testing.T) { + t.Parallel() + + // openclaw agent rejects --model and --system-prompt; verify they are + // never emitted as flags even when Model and SystemPrompt are set. + args := buildOpenclawArgs("task", "ses-2", ExecOptions{ + Model: "gpt-4o", + SystemPrompt: "You are a helpful agent.", + }, slog.Default()) + + if idx := indexOf(args, "--model"); idx != -1 { + t.Fatalf("unexpected --model flag at %d: %v", idx, args) + } + if idx := indexOf(args, "--system-prompt"); idx != -1 { + t.Fatalf("unexpected --system-prompt flag at %d: %v", idx, args) + } +} + +func TestBuildOpenclawArgsPrependsSystemPromptToMessage(t *testing.T) { + t.Parallel() + + args := buildOpenclawArgs("do the thing", "ses-3", ExecOptions{ + SystemPrompt: "You are a read-only agent.", + }, slog.Default()) + + msgIdx := indexOf(args, "--message") + if msgIdx == -1 || msgIdx+1 >= len(args) { + t.Fatalf("expected --message in args: %v", args) + } + got := args[msgIdx+1] + want := "You are a read-only agent.\n\ndo the thing" + if got != want { + t.Errorf("--message payload mismatch:\n got: %q\n want: %q", got, want) + } +} + +func TestBuildOpenclawArgsEmptySystemPromptLeavesMessageUnchanged(t *testing.T) { + t.Parallel() + + args := buildOpenclawArgs("just do it", "ses-4", ExecOptions{}, slog.Default()) + + msgIdx := indexOf(args, "--message") + if msgIdx == -1 || msgIdx+1 >= len(args) { + t.Fatalf("expected --message in args: %v", args) + } + if got := args[msgIdx+1]; got != "just do it" { + t.Errorf("--message payload: got %q, want %q", got, "just do it") + } +} + +func TestBuildOpenclawArgsTimeout(t *testing.T) { + t.Parallel() + + args := buildOpenclawArgs("task", "ses-5", ExecOptions{ + Timeout: 90 * time.Second, + }, slog.Default()) + + idx := indexOf(args, "--timeout") + if idx == -1 || idx+1 >= len(args) { + t.Fatalf("expected --timeout in args: %v", args) + } + if got := args[idx+1]; got != "90" { + t.Errorf("--timeout value: got %q, want %q", got, "90") + } +} + +func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) { + t.Parallel() + + // Users must not be able to re-introduce the banned flags via custom_args — + // they would crash `openclaw agent` just like the direct forward did. + args := buildOpenclawArgs("task", "ses-6", ExecOptions{ + CustomArgs: []string{ + "--agent", "research-bot", + "--model", "gpt-4o", + "--system-prompt", "You are helpful", + "--session-id", "hijacked", + "--message", "hijacked", + }, + }, slog.Default()) + + if idx := indexOf(args, "--model"); idx != -1 { + t.Errorf("--model should be filtered from custom_args: %v", args) + } + if idx := indexOf(args, "--system-prompt"); idx != -1 { + t.Errorf("--system-prompt should be filtered from custom_args: %v", args) + } + // Whitelisted pass-through flag must survive filtering. + if idx := indexOf(args, "--agent"); idx == -1 || idx+1 >= len(args) || args[idx+1] != "research-bot" { + t.Errorf("expected --agent research-bot to survive filtering: %v", args) + } + // --session-id and --message appear exactly once — the daemon-managed ones. + if count := countOccurrences(args, "--session-id"); count != 1 { + t.Errorf("expected 1 --session-id (daemon-managed), got %d: %v", count, args) + } + if count := countOccurrences(args, "--message"); count != 1 { + t.Errorf("expected 1 --message (daemon-managed), got %d: %v", count, args) + } +} + +func countOccurrences(args []string, s string) int { + n := 0 + for _, a := range args { + if a == s { + n++ + } + } + return n +}