Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion server/cmd/multica/cmd_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -31,6 +32,7 @@ var configSetSupportedKeys = []string{
"server_url",
"app_url",
"workspace_id",
"codex_path",
"device_name",
"runtime_name",
"max_concurrent_tasks",
Expand All @@ -48,7 +50,7 @@ var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a CLI configuration value",
Long: "Supported keys: " +
"server_url, app_url, workspace_id, " +
"server_url, app_url, workspace_id, codex_path, " +
"device_name, runtime_name, max_concurrent_tasks, poll_interval, " +
"heartbeat_interval, agent_timeout, " +
"codex_semantic_inactivity_timeout, codex_handshake_timeout, " +
Expand Down Expand Up @@ -95,6 +97,7 @@ func runConfigShow(cmd *cobra.Command, _ []string) error {
fmt.Fprintf(os.Stdout, "%-34s %s\n", "server_url:", valueOrDefault(cfg.ServerURL, "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "app_url:", valueOrDefault(cfg.AppURL, "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "workspace_id:", valueOrDefault(cfg.WorkspaceID, "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "codex_path:", valueOrDefault(codexPath(cfg), "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "device_name:", valueOrDefault(cfg.DeviceName, "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "runtime_name:", valueOrDefault(cfg.RuntimeName, "(not set)"))
fmt.Fprintf(os.Stdout, "%-34s %s\n", "max_concurrent_tasks:", intOrDefault(cfg.MaxConcurrentTasks, "(not set)"))
Expand Down Expand Up @@ -151,6 +154,23 @@ func applyConfigSet(cfg *cli.CLIConfig, key, value string) error {
cfg.AppURL = value
case "workspace_id":
cfg.WorkspaceID = value
case "codex_path":
if value == "" {
if cfg.Backends != nil {
cfg.Backends.Codex = nil
if cfg.Backends.OpenClaw == nil {
cfg.Backends = nil
}
}
return nil
}
if !filepath.IsAbs(value) {
return fmt.Errorf("codex_path must be an absolute path (got %q)", value)
}
if cfg.Backends == nil {
cfg.Backends = &cli.BackendOverrides{}
}
cfg.Backends.Codex = &cli.CodexOverride{BinaryPath: filepath.Clean(value)}
case "device_name":
cfg.DeviceName = value
case "runtime_name":
Expand Down Expand Up @@ -236,6 +256,13 @@ func applyConfigSet(cfg *cli.CLIConfig, key, value string) error {
return nil
}

func codexPath(cfg cli.CLIConfig) string {
if cfg.Backends == nil || cfg.Backends.Codex == nil {
return ""
}
return cfg.Backends.Codex.BinaryPath
}

// assignBool parses value as a strict bool into dst. Shared by the
// disable_* toggles. Empty string clears the field (false).
func assignBool(dst *bool, key, value string) error {
Expand Down
31 changes: 31 additions & 0 deletions server/cmd/multica/cmd_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) {
"server_url:",
"app_url:",
"workspace_id:",
"codex_path:",
"device_name:",
"runtime_name:",
"max_concurrent_tasks:",
Expand Down Expand Up @@ -196,6 +197,7 @@ func TestApplyConfigSetSupportsDaemonKeys(t *testing.T) {

cfg := cli.CLIConfig{}
pairs := []struct{ key, val string }{
{"codex_path", "/opt/company/bin/mcodex"},
{"device_name", "vm-1-custom-name"},
{"runtime_name", "worker-a"},
{"max_concurrent_tasks", "4"},
Expand All @@ -213,6 +215,7 @@ func TestApplyConfigSetSupportsDaemonKeys(t *testing.T) {
}
}
if cfg.DeviceName != "vm-1-custom-name" ||
codexPath(cfg) != "/opt/company/bin/mcodex" ||
cfg.RuntimeName != "worker-a" ||
cfg.MaxConcurrentTasks != 4 ||
cfg.PollInterval != "10s" ||
Expand All @@ -226,6 +229,34 @@ func TestApplyConfigSetSupportsDaemonKeys(t *testing.T) {
}
}

func TestApplyConfigSetCodexPathValidationAndClear(t *testing.T) {
t.Parallel()

cfg := cli.CLIConfig{
Backends: &cli.BackendOverrides{
OpenClaw: &cli.OpenClawOverride{StateDir: "/var/lib/openclaw"},
},
}
if err := applyConfigSet(&cfg, "codex_path", "relative/mcodex"); err == nil {
t.Fatal("relative codex_path should be rejected")
}
if err := applyConfigSet(&cfg, "codex_path", "/opt/company/bin/../bin/mcodex"); err != nil {
t.Fatalf("set codex_path: %v", err)
}
if got := codexPath(cfg); got != "/opt/company/bin/mcodex" {
t.Fatalf("codex_path = %q, want cleaned absolute path", got)
}
if err := applyConfigSet(&cfg, "codex_path", ""); err != nil {
t.Fatalf("clear codex_path: %v", err)
}
if got := codexPath(cfg); got != "" {
t.Fatalf("codex_path after clear = %q", got)
}
if cfg.Backends == nil || cfg.Backends.OpenClaw == nil {
t.Fatal("clearing codex_path should preserve unrelated backend overrides")
}
}

func TestApplyConfigSetPositiveDurationRoundTripsToDaemonResolver(t *testing.T) {
const envName = "TEST_MULTICA_PERSISTED_DURATION"
t.Setenv(envName, "")
Expand Down
12 changes: 12 additions & 0 deletions server/internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,21 @@ type CLIConfig struct {
// Go's encoding/json drops fields that are not represented in this struct on
// load/save round-trip (see TestCLIConfig_UnknownFieldsArePreserved).
type BackendOverrides struct {
Codex *CodexOverride `json:"codex,omitempty"`
OpenClaw *OpenClawOverride `json:"openclaw,omitempty"`
}

// CodexOverride configures the Codex executable used by daemon starts on this
// machine. Persisting the path closes the gap where an environment-only
// MULTICA_CODEX_PATH override is lost after a manual daemon restart.
//
// Resolution precedence:
//
// MULTICA_CODEX_PATH (env) > backends.codex.binary_path > PATH lookup
type CodexOverride struct {
BinaryPath string `json:"binary_path,omitempty"`
}

// OpenClawOverride configures the OpenClaw backend. All fields are optional;
// empty values fall through to the existing discovery path (PATH lookup for
// BinaryPath, default `~/.openclaw/` for StateDir).
Expand Down
26 changes: 26 additions & 0 deletions server/internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,32 @@ func TestCLIConfig_OpenClawOverride_RoundTrip(t *testing.T) {
}
}

func TestCLIConfig_CodexOverride_RoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

original := CLIConfig{
ServerURL: "https://api.multica.ai",
Backends: &BackendOverrides{
Codex: &CodexOverride{BinaryPath: "/opt/company/bin/mcodex"},
},
}
if err := SaveCLIConfig(original); err != nil {
t.Fatal(err)
}

loaded, err := LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
if loaded.Backends == nil || loaded.Backends.Codex == nil {
t.Fatalf("Backends.Codex should be non-nil after round-trip, got %+v", loaded.Backends)
}
if got := loaded.Backends.Codex.BinaryPath; got != "/opt/company/bin/mcodex" {
t.Errorf("Codex BinaryPath round-trip: got %q", got)
}
}

// TestCLIConfig_OpenClawOverride_PartialFieldsOmitted verifies that an
// override with only one field set does not emit empty strings for the
// unset field. Important so users can intentionally set only BinaryPath
Expand Down
25 changes: 25 additions & 0 deletions server/internal/daemon/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
slog.Warn("could not load CLI config for backend overrides; proceeding without",
"profile", overrides.Profile, "err", err)
} else {
if codex := codexOverrideFrom(cliCfg); codex != nil {
restore := applyCodexOverride(codex)
defer restore()
}
if oc := openclawOverrideFrom(cliCfg); oc != nil {
applyOpenclawOverride(oc)
}
Expand Down Expand Up @@ -1011,6 +1015,27 @@ func openclawOverrideFrom(cfg cli.CLIConfig) *cli.OpenClawOverride {
return cfg.Backends.OpenClaw
}

func codexOverrideFrom(cfg cli.CLIConfig) *cli.CodexOverride {
if cfg.Backends == nil {
return nil
}
return cfg.Backends.Codex
}

// applyCodexOverride translates the persisted Codex path into the existing
// environment-based probe contract. An explicitly exported environment value
// remains authoritative for backward compatibility.
func applyCodexOverride(codex *cli.CodexOverride) func() {
if codex == nil || codex.BinaryPath == "" {
return func() {}
}
if _, set := os.LookupEnv("MULTICA_CODEX_PATH"); set {
return func() {}
}
_ = os.Setenv("MULTICA_CODEX_PATH", codex.BinaryPath)
return func() { _ = os.Unsetenv("MULTICA_CODEX_PATH") }
}

// applyOpenclawOverride translates the config-file overrides into process
// env vars, which the existing probe() / buildEnv code paths already honor.
// Env-set-by-user wins over config-set-by-file: we only Setenv when the var
Expand Down
78 changes: 78 additions & 0 deletions server/internal/daemon/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ func TestLoadConfig_SkipsLoginShellWhenLookPathSucceeds(t *testing.T) {
}

func TestLoadConfig_UsesCodexDesktopAppBundleFallback(t *testing.T) {
t.Setenv("HOME", t.TempDir())
pathDir := t.TempDir()
fakeCodex := filepath.Join(pathDir, "Codex.app", "Contents", "Resources", "codex")
if err := os.MkdirAll(filepath.Dir(fakeCodex), 0o755); err != nil {
Expand Down Expand Up @@ -886,6 +887,7 @@ func TestLoadConfig_UsesCodexDesktopAppBundleFallback(t *testing.T) {
// Multica must resolve the bundled CLI under ChatGPT.app (and prefer it over
// the legacy Codex.app path when both exist).
func TestLoadConfig_UsesChatGPTAppBundleCodexPath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
pathDir := t.TempDir()
fakeChatGPT := filepath.Join(pathDir, "ChatGPT.app", "Contents", "Resources", "codex")
fakeLegacy := filepath.Join(pathDir, "Codex.app", "Contents", "Resources", "codex")
Expand Down Expand Up @@ -1141,6 +1143,82 @@ func TestOpenclawOverrideFrom_NavigationCases(t *testing.T) {
}
}

func TestApplyCodexOverride_EnvWinsOverConfig(t *testing.T) {
t.Setenv("MULTICA_CODEX_PATH", "/from/env/codex")

restore := applyCodexOverride(&cli.CodexOverride{BinaryPath: "/from/config/mcodex"})
restore()

if got := os.Getenv("MULTICA_CODEX_PATH"); got != "/from/env/codex" {
t.Errorf("MULTICA_CODEX_PATH: env should win, got %q", got)
}
}

func TestApplyCodexOverride_RestoresPreviouslyUnsetEnvironment(t *testing.T) {
os.Unsetenv("MULTICA_CODEX_PATH")
t.Cleanup(func() { os.Unsetenv("MULTICA_CODEX_PATH") })

restore := applyCodexOverride(&cli.CodexOverride{BinaryPath: "/from/config/mcodex"})
if got := os.Getenv("MULTICA_CODEX_PATH"); got != "/from/config/mcodex" {
t.Fatalf("MULTICA_CODEX_PATH during probe = %q", got)
}
restore()
if _, set := os.LookupEnv("MULTICA_CODEX_PATH"); set {
t.Fatal("MULTICA_CODEX_PATH should be restored to unset after probe")
}
}

func TestCodexOverrideFrom_NavigationCases(t *testing.T) {
if got := codexOverrideFrom(cli.CLIConfig{}); got != nil {
t.Errorf("nil Backends should produce nil override, got %+v", got)
}
if got := codexOverrideFrom(cli.CLIConfig{Backends: &cli.BackendOverrides{}}); got != nil {
t.Errorf("nil Codex inside Backends should produce nil override, got %+v", got)
}
want := &cli.CodexOverride{BinaryPath: "/x/mcodex"}
got := codexOverrideFrom(cli.CLIConfig{Backends: &cli.BackendOverrides{Codex: want}})
if got != want {
t.Errorf("happy path should return inner pointer; got %p want %p", got, want)
}
}

func TestLoadConfig_AppliesCodexOverrideFromConfigFile(t *testing.T) {
stageFakeAgent(t)
customDir := t.TempDir()
customCodex := filepath.Join(customDir, "mcodex")
if err := os.WriteFile(customCodex, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake codex: %v", err)
}

os.Unsetenv("MULTICA_CODEX_PATH")
t.Cleanup(func() { os.Unsetenv("MULTICA_CODEX_PATH") })
homeForCLIConfig := t.TempDir()
t.Setenv("HOME", homeForCLIConfig)
if err := cli.SaveCLIConfig(cli.CLIConfig{
ServerURL: "http://localhost:8080",
Backends: &cli.BackendOverrides{
Codex: &cli.CodexOverride{BinaryPath: customCodex},
},
}); err != nil {
t.Fatalf("save cli config: %v", err)
}

loaded, err := LoadConfig(Overrides{
ServerURL: "http://localhost:8080",
WorkspacesRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
codex, ok := loaded.Agents["codex"]
if !ok {
t.Fatalf("agents map missing codex; got keys=%v", agentKeys(loaded.Agents))
}
if codex.Path != customCodex {
t.Errorf("codex.Path: got %q, want %q", codex.Path, customCodex)
}
}

// TestLoadConfig_AppliesBackendOverridesFromConfigFile is the integration
// test that ties commit 1's schema to commit 2's wire-up: write a config
// file with backends.openclaw.{binary_path,state_dir}, call LoadConfig
Expand Down
16 changes: 12 additions & 4 deletions server/internal/handler/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {

link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
writeStorageUploadError(w, err)
return
}
params.Url = link
Expand All @@ -575,8 +574,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
// No workspace context (e.g. avatar upload) — upload directly.
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
writeStorageUploadError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{
Expand All @@ -586,6 +584,16 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
})
}

func writeStorageUploadError(w http.ResponseWriter, err error) {
slog.Error("file upload failed", "error", err)
writeErrorCode(
w,
http.StatusBadGateway,
"storage_upload_failed",
"file storage rejected the upload; contact an administrator",
)
}

// ---------------------------------------------------------------------------
// ListAttachments — GET /api/issues/{id}/attachments
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading