mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +02:00
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
300 lines
9.7 KiB
Go
300 lines
9.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestCLIConfig_BackwardCompat_OldFileLoadsWithNilBackends verifies that a
|
|
// config.json written by an older daemon (no `backends` key at all) loads
|
|
// correctly into the new schema, with Backends == nil. This is the most
|
|
// important guarantee of issue #3875's PR: existing on-disk configs MUST
|
|
// continue to work byte-for-byte.
|
|
func TestCLIConfig_BackwardCompat_OldFileLoadsWithNilBackends(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
// Write a 4-field config exactly as the historical daemon would have.
|
|
cfgDir := filepath.Join(tmp, ".multica")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
historical := `{
|
|
"server_url": "https://api.multica.ai",
|
|
"app_url": "https://app.multica.ai",
|
|
"workspace_id": "ws-123",
|
|
"token": "mul_abcdef"
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(historical), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadCLIConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadCLIConfig on historical file: %v", err)
|
|
}
|
|
|
|
if cfg.ServerURL != "https://api.multica.ai" {
|
|
t.Errorf("ServerURL: got %q, want historical value", cfg.ServerURL)
|
|
}
|
|
if cfg.Token != "mul_abcdef" {
|
|
t.Errorf("Token: got %q, want historical value", cfg.Token)
|
|
}
|
|
if cfg.Backends != nil {
|
|
t.Errorf("Backends should be nil for historical config, got %+v", cfg.Backends)
|
|
}
|
|
}
|
|
|
|
// TestCLIConfig_BackwardCompat_NilBackendsOmittedFromJSON verifies that
|
|
// saving a config without backend overrides does NOT add a `backends` key
|
|
// to the on-disk JSON. This matters for users who never set overrides —
|
|
// their config files must stay byte-identical, so a future downgrade to
|
|
// an older daemon doesn't trip on an empty `backends: null` line.
|
|
func TestCLIConfig_BackwardCompat_NilBackendsOmittedFromJSON(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
cfg := CLIConfig{
|
|
ServerURL: "https://api.multica.ai",
|
|
Token: "mul_xyz",
|
|
}
|
|
if err := SaveCLIConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) == "" {
|
|
t.Fatal("config file is empty")
|
|
}
|
|
|
|
// The omitempty tag on Backends should keep it out of the JSON entirely.
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatalf("unmarshal saved config: %v", err)
|
|
}
|
|
if _, ok := raw["backends"]; ok {
|
|
t.Errorf("backends key should be omitted when nil, got: %s", string(data))
|
|
}
|
|
}
|
|
|
|
// TestCLIConfig_OpenClawOverride_RoundTrip verifies that setting BinaryPath
|
|
// and StateDir survives a save/load cycle.
|
|
func TestCLIConfig_OpenClawOverride_RoundTrip(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
original := CLIConfig{
|
|
ServerURL: "https://api.multica.ai",
|
|
Token: "mul_xyz",
|
|
Backends: &BackendOverrides{
|
|
OpenClaw: &OpenClawOverride{
|
|
BinaryPath: "/opt/openclaw-prod/bin/openclaw",
|
|
StateDir: "/var/lib/openclaw-prod",
|
|
},
|
|
},
|
|
}
|
|
if err := SaveCLIConfig(original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loaded, err := LoadCLIConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if loaded.Backends == nil || loaded.Backends.OpenClaw == nil {
|
|
t.Fatalf("Backends.OpenClaw should be non-nil after round-trip, got %+v", loaded.Backends)
|
|
}
|
|
if loaded.Backends.OpenClaw.BinaryPath != original.Backends.OpenClaw.BinaryPath {
|
|
t.Errorf("BinaryPath round-trip: got %q, want %q",
|
|
loaded.Backends.OpenClaw.BinaryPath, original.Backends.OpenClaw.BinaryPath)
|
|
}
|
|
if loaded.Backends.OpenClaw.StateDir != original.Backends.OpenClaw.StateDir {
|
|
t.Errorf("StateDir round-trip: got %q, want %q",
|
|
loaded.Backends.OpenClaw.StateDir, original.Backends.OpenClaw.StateDir)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// (or only StateDir) and have the other follow the historical default,
|
|
// without an empty string overriding env-var precedence.
|
|
func TestCLIConfig_OpenClawOverride_PartialFieldsOmitted(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
cfg := CLIConfig{
|
|
ServerURL: "https://api.multica.ai",
|
|
Token: "mul_xyz",
|
|
Backends: &BackendOverrides{
|
|
OpenClaw: &OpenClawOverride{
|
|
StateDir: "/var/lib/openclaw-prod",
|
|
// BinaryPath intentionally left empty
|
|
},
|
|
},
|
|
}
|
|
if err := SaveCLIConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
openclaw, ok := raw["backends"].(map[string]any)["openclaw"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("could not navigate to backends.openclaw in: %s", string(data))
|
|
}
|
|
if _, present := openclaw["binary_path"]; present {
|
|
t.Errorf("binary_path should be omitted when empty, got: %s", string(data))
|
|
}
|
|
if _, present := openclaw["state_dir"]; !present {
|
|
t.Errorf("state_dir should be present when set, got: %s", string(data))
|
|
}
|
|
}
|
|
|
|
// TestCLIConfig_ProfileCommandOverrides_RoundTrip verifies that pinning a
|
|
// per-machine profile command path survives a save/load cycle AND that
|
|
// unrelated fields (server_url, token, backends) are preserved across the
|
|
// round-trip — the set-path / unset-path CLI commands rely on a
|
|
// load->modify->save cycle never dropping config the user already had.
|
|
func TestCLIConfig_ProfileCommandOverrides_RoundTrip(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
original := CLIConfig{
|
|
ServerURL: "https://api.multica.ai",
|
|
AppURL: "https://app.multica.ai",
|
|
WorkspaceID: "ws-123",
|
|
Token: "mul_xyz",
|
|
Backends: &BackendOverrides{
|
|
OpenClaw: &OpenClawOverride{StateDir: "/var/lib/openclaw-prod"},
|
|
},
|
|
ProfileCommandOverrides: map[string]string{
|
|
"prof-1": "/opt/bin/company-codex",
|
|
"prof-2": "/usr/local/bin/special-claude",
|
|
},
|
|
}
|
|
if err := SaveCLIConfig(original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loaded, err := LoadCLIConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The override map must round-trip intact.
|
|
if len(loaded.ProfileCommandOverrides) != 2 {
|
|
t.Fatalf("ProfileCommandOverrides len = %d, want 2: %+v", len(loaded.ProfileCommandOverrides), loaded.ProfileCommandOverrides)
|
|
}
|
|
if got := loaded.ProfileCommandOverrides["prof-1"]; got != "/opt/bin/company-codex" {
|
|
t.Errorf("prof-1 override = %q, want /opt/bin/company-codex", got)
|
|
}
|
|
if got := loaded.ProfileCommandOverrides["prof-2"]; got != "/usr/local/bin/special-claude" {
|
|
t.Errorf("prof-2 override = %q, want /usr/local/bin/special-claude", got)
|
|
}
|
|
|
|
// Every other field must be preserved (no clobbering on round-trip).
|
|
if loaded.ServerURL != original.ServerURL {
|
|
t.Errorf("ServerURL = %q, want %q", loaded.ServerURL, original.ServerURL)
|
|
}
|
|
if loaded.AppURL != original.AppURL {
|
|
t.Errorf("AppURL = %q, want %q", loaded.AppURL, original.AppURL)
|
|
}
|
|
if loaded.WorkspaceID != original.WorkspaceID {
|
|
t.Errorf("WorkspaceID = %q, want %q", loaded.WorkspaceID, original.WorkspaceID)
|
|
}
|
|
if loaded.Token != original.Token {
|
|
t.Errorf("Token = %q, want %q", loaded.Token, original.Token)
|
|
}
|
|
if loaded.Backends == nil || loaded.Backends.OpenClaw == nil ||
|
|
loaded.Backends.OpenClaw.StateDir != "/var/lib/openclaw-prod" {
|
|
t.Errorf("Backends.OpenClaw not preserved: %+v", loaded.Backends)
|
|
}
|
|
}
|
|
|
|
// TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty verifies the
|
|
// omitempty tag keeps the key out of the on-disk JSON when no overrides are
|
|
// set, so configs for users who never pin a path stay byte-stable.
|
|
func TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
cfg := CLIConfig{ServerURL: "https://api.multica.ai", Token: "mul_xyz"}
|
|
if err := SaveCLIConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := raw["profile_command_overrides"]; ok {
|
|
t.Errorf("profile_command_overrides should be omitted when empty, got: %s", string(data))
|
|
}
|
|
}
|
|
|
|
// TestCLIConfig_UnknownFieldsArePreserved verifies forward-compat: a future
|
|
// daemon that adds, say, a `backends.codex` key should not have its data
|
|
// destroyed when an older daemon (without knowledge of that key) reads and
|
|
// re-saves the file. Today Go's encoding/json silently DROPS unknown fields
|
|
// on round-trip. This test documents the gap so future maintainers know.
|
|
//
|
|
// Skipped today (encoding/json does not preserve unknown fields), but the
|
|
// test is written so a future change to a preserve-unknown encoder
|
|
// (json.RawMessage, mapstructure, etc.) will pick it up.
|
|
func TestCLIConfig_UnknownFieldsArePreserved(t *testing.T) {
|
|
t.Skip("documenting known limitation: encoding/json drops unknown fields on round-trip; future PR can switch to a preserving encoder")
|
|
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", tmp)
|
|
|
|
cfgDir := filepath.Join(tmp, ".multica")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withFutureField := `{
|
|
"server_url": "https://api.multica.ai",
|
|
"token": "mul_xyz",
|
|
"backends": {
|
|
"openclaw": {"state_dir": "/x"},
|
|
"future_backend_xyz": {"some_setting": "preserve me"}
|
|
}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(withFutureField), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadCLIConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveCLIConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// After round-trip, future_backend_xyz should still be in the file.
|
|
data, _ := os.ReadFile(filepath.Join(cfgDir, "config.json"))
|
|
if !strings.Contains(string(data), "future_backend_xyz") {
|
|
t.Error("unknown field future_backend_xyz was dropped on round-trip")
|
|
}
|
|
}
|