@@ -763,34 +739,3 @@ function ProfileDetailsForm({ ); } - -function VisibilityOption({ - active, - label, - hint, - onClick, -}: { - active: boolean; - label: string; - hint: string; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/server/cmd/multica/cmd_runtime_profile.go b/server/cmd/multica/cmd_runtime_profile.go index a9a35db04..14623a239 100644 --- a/server/cmd/multica/cmd_runtime_profile.go +++ b/server/cmd/multica/cmd_runtime_profile.go @@ -94,7 +94,6 @@ func init() { runtimeProfileCreateCmd.Flags().String("command-name", "", "Executable the daemon resolves on PATH (required)") runtimeProfileCreateCmd.Flags().String("display-name", "", "Human-readable profile name (required)") runtimeProfileCreateCmd.Flags().String("description", "", "Optional description") - runtimeProfileCreateCmd.Flags().String("visibility", "", "Visibility: workspace or private") runtimeProfileCreateCmd.Flags().String("output", "json", "Output format: table or json") // update @@ -106,7 +105,6 @@ func init() { // args to the agent launch command, so a CLI flag would promise admins a // no-op. Re-add once it's wired end-to-end (TODO(MUL-3284), see // server/internal/daemon/daemon.go). - runtimeProfileUpdateCmd.Flags().String("visibility", "", "New visibility: workspace or private") runtimeProfileUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the profile") runtimeProfileUpdateCmd.Flags().String("output", "json", "Output format: table or json") @@ -130,17 +128,10 @@ func validateProtocolFamily(family string) error { return nil } -// validateVisibility rejects anything other than the two server-accepted -// visibility values. An empty string is allowed (means "let the server pick -// its default" on create, "leave unchanged" on update). -func validateVisibility(visibility string) error { - switch visibility { - case "", "workspace", "private": - return nil - default: - return fmt.Errorf("invalid --visibility %q: must be workspace or private", visibility) - } -} +// NOTE: a --visibility flag is intentionally NOT exposed in v1. The server +// forces every profile to 'workspace' because the read paths do not yet +// enforce 'private' (exposing it would leak "private" profiles). Re-add once +// creator-visibility filtering exists. Follow-up: MUL-3308. func runRuntimeProfileList(cmd *cobra.Command, _ []string) error { client, err := newAPIClient(cmd) @@ -175,7 +166,6 @@ func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error { commandName, _ := cmd.Flags().GetString("command-name") displayName, _ := cmd.Flags().GetString("display-name") description, _ := cmd.Flags().GetString("description") - visibility, _ := cmd.Flags().GetString("visibility") if strings.TrimSpace(family) == "" { return fmt.Errorf("--protocol-family is required") @@ -189,9 +179,6 @@ func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error { if err := validateProtocolFamily(family); err != nil { return err } - if err := validateVisibility(visibility); err != nil { - return err - } client, err := newAPIClient(cmd) if err != nil { @@ -210,9 +197,6 @@ func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error { if description != "" { body["description"] = description } - if visibility != "" { - body["visibility"] = visibility - } ctx, cancel := cli.APIContext(context.Background()) defer cancel() @@ -240,20 +224,13 @@ func runRuntimeProfileUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("description") body["description"] = v } - if cmd.Flags().Changed("visibility") { - v, _ := cmd.Flags().GetString("visibility") - if err := validateVisibility(v); err != nil { - return err - } - body["visibility"] = v - } if cmd.Flags().Changed("enabled") { v, _ := cmd.Flags().GetBool("enabled") body["enabled"] = v } if len(body) == 0 { - return fmt.Errorf("no fields to update: pass at least one of --display-name, --command-name, --description, --visibility, --enabled") + return fmt.Errorf("no fields to update: pass at least one of --display-name, --command-name, --description, --enabled") } client, err := newAPIClient(cmd) @@ -375,7 +352,7 @@ func outputRuntimeProfile(cmd *cobra.Command, profile map[string]any) error { // printRuntimeProfileTable renders profiles as a stable, sorted table. func printRuntimeProfileTable(profiles []map[string]any) { - headers := []string{"ID", "DISPLAY_NAME", "PROTOCOL_FAMILY", "COMMAND_NAME", "VISIBILITY", "ENABLED"} + headers := []string{"ID", "DISPLAY_NAME", "PROTOCOL_FAMILY", "COMMAND_NAME", "ENABLED"} rows := make([][]string, 0, len(profiles)) for _, p := range profiles { rows = append(rows, []string{ @@ -383,7 +360,6 @@ func printRuntimeProfileTable(profiles []map[string]any) { strVal(p, "display_name"), strVal(p, "protocol_family"), strVal(p, "command_name"), - strVal(p, "visibility"), strVal(p, "enabled"), }) } diff --git a/server/cmd/multica/cmd_runtime_profile_test.go b/server/cmd/multica/cmd_runtime_profile_test.go index 7dc6f497b..11a4b782b 100644 --- a/server/cmd/multica/cmd_runtime_profile_test.go +++ b/server/cmd/multica/cmd_runtime_profile_test.go @@ -36,7 +36,6 @@ func newProfileCreateTestCmd() *cobra.Command { cmd.Flags().String("command-name", "", "") cmd.Flags().String("display-name", "", "") cmd.Flags().String("description", "", "") - cmd.Flags().String("visibility", "", "") cmd.Flags().String("output", "json", "") return cmd } @@ -47,7 +46,6 @@ func newProfileUpdateTestCmd() *cobra.Command { cmd.Flags().String("display-name", "", "") cmd.Flags().String("command-name", "", "") cmd.Flags().String("description", "", "") - cmd.Flags().String("visibility", "", "") cmd.Flags().Bool("enabled", true, "") cmd.Flags().String("output", "json", "") return cmd @@ -144,7 +142,6 @@ func TestRunRuntimeProfileCreate(t *testing.T) { _ = cmd.Flags().Set("protocol-family", "codex") _ = cmd.Flags().Set("command-name", "company-codex") _ = cmd.Flags().Set("display-name", "Company Codex") - _ = cmd.Flags().Set("visibility", "workspace") if err := runRuntimeProfileCreate(cmd, nil); err != nil { t.Fatalf("runRuntimeProfileCreate: %v", err) @@ -163,8 +160,10 @@ func TestRunRuntimeProfileCreate(t *testing.T) { if _, present := gotBody["fixed_args"]; present { t.Errorf("fixed_args must not be sent by the CLI, got %#v", gotBody["fixed_args"]) } - if gotBody["visibility"] != "workspace" { - t.Errorf("visibility = %v, want workspace", gotBody["visibility"]) + // visibility is intentionally NOT exposed by the CLI in v1 (server forces + // 'workspace'), so it must never be sent. + if _, present := gotBody["visibility"]; present { + t.Errorf("visibility must not be sent by the CLI, got %#v", gotBody["visibility"]) } } diff --git a/server/internal/handler/runtime_profile.go b/server/internal/handler/runtime_profile.go index e052bdbb5..393c91809 100644 --- a/server/internal/handler/runtime_profile.go +++ b/server/internal/handler/runtime_profile.go @@ -69,10 +69,15 @@ func runtimeProfileToResponse(p db.RuntimeProfile) RuntimeProfileResponse { } } -// validRuntimeProfileVisibility mirrors the CHECK in migration 120. -func validRuntimeProfileVisibility(v string) bool { - return v == "workspace" || v == "private" -} +// NOTE: runtime_profile.visibility is intentionally NOT user-settable in v1. +// The column exists and the API still returns it, but creation always forces +// 'workspace': the daemon-pull, DaemonRegister and ListRuntimeProfiles read +// paths do not yet enforce 'private', so accepting 'private' from a client +// would silently leak a "private" profile's name/command to other members and +// let other machines' daemons register it (lateral data leak). Re-expose a +// visibility control only once those read paths enforce creator visibility. +// Follow-up: MUL-3308. +const runtimeProfileDefaultVisibility = "workspace" // marshalFixedArgs validates and JSON-encodes the fixed_args list. Each entry // must be a non-empty string; the column defaults to an empty array. @@ -98,7 +103,6 @@ type createRuntimeProfileRequest struct { CommandName string `json:"command_name"` Description *string `json:"description"` FixedArgs []string `json:"fixed_args"` - Visibility string `json:"visibility"` Enabled *bool `json:"enabled"` } @@ -124,7 +128,6 @@ func (h *Handler) CreateRuntimeProfile(w http.ResponseWriter, r *http.Request) { req.DisplayName = strings.TrimSpace(req.DisplayName) req.ProtocolFamily = strings.TrimSpace(req.ProtocolFamily) req.CommandName = strings.TrimSpace(req.CommandName) - req.Visibility = strings.TrimSpace(req.Visibility) if req.DisplayName == "" { writeError(w, http.StatusBadRequest, "display_name is required") @@ -138,13 +141,6 @@ func (h *Handler) CreateRuntimeProfile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "command_name is required") return } - if req.Visibility == "" { - req.Visibility = "workspace" - } - if !validRuntimeProfileVisibility(req.Visibility) { - writeError(w, http.StatusBadRequest, "visibility must be 'workspace' or 'private'") - return - } fixedArgs, err := marshalFixedArgs(req.FixedArgs) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -162,7 +158,7 @@ func (h *Handler) CreateRuntimeProfile(w http.ResponseWriter, r *http.Request) { CommandName: req.CommandName, Description: ptrToText(req.Description), FixedArgs: fixedArgs, - Visibility: req.Visibility, + Visibility: runtimeProfileDefaultVisibility, CreatedBy: member.UserID, Enabled: enabled, }) @@ -238,7 +234,6 @@ type updateRuntimeProfileRequest struct { CommandName *string `json:"command_name"` Description *string `json:"description"` FixedArgs *[]string `json:"fixed_args"` - Visibility *string `json:"visibility"` Enabled *bool `json:"enabled"` } @@ -294,14 +289,6 @@ func (h *Handler) UpdateRuntimeProfile(w http.ResponseWriter, r *http.Request) { } params.FixedArgs = fixedArgs } - if req.Visibility != nil { - vis := strings.TrimSpace(*req.Visibility) - if !validRuntimeProfileVisibility(vis) { - writeError(w, http.StatusBadRequest, "visibility must be 'workspace' or 'private'") - return - } - params.Visibility = strToText(vis) - } if req.Enabled != nil { params.Enabled = pgtype.Bool{Bool: *req.Enabled, Valid: true} } diff --git a/server/internal/handler/runtime_profile_handler_test.go b/server/internal/handler/runtime_profile_handler_test.go index 2ccea819e..77e7fb077 100644 --- a/server/internal/handler/runtime_profile_handler_test.go +++ b/server/internal/handler/runtime_profile_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -135,3 +136,50 @@ func TestDeleteRuntimeProfile_ActiveAgentBlocks(t *testing.T) { t.Fatalf("expected runtime to survive 409, found %d", rtRows) } } + + +// TestCreateRuntimeProfile_ForcesWorkspaceVisibility is the regression guard +// for the visibility leak: visibility=private is not user-settable in v1 +// because the read paths don't enforce it. A client that POSTs +// visibility:"private" must get a profile stored as 'workspace' — never +// private — so a "private" profile can't leak to other members or be +// registered by other daemons. Belt-and-suspenders: also assert the row in +// the DB is 'workspace'. +func TestCreateRuntimeProfile_ForcesWorkspaceVisibility(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles", map[string]any{ + "display_name": "Visibility Forced Profile", + "protocol_family": "codex", + "command_name": "vis-forced-codex", + "visibility": "private", // must be ignored + }) + req = withURLParam(req, "id", testWorkspaceID) + testHandler.CreateRuntimeProfile(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp RuntimeProfileResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, resp.ID) + }) + + if resp.Visibility != "workspace" { + t.Fatalf("response visibility = %q, want workspace (private must be forced to workspace)", resp.Visibility) + } + var dbVis string + if err := testPool.QueryRow(ctx, `SELECT visibility FROM runtime_profile WHERE id = $1`, resp.ID).Scan(&dbVis); err != nil { + t.Fatalf("read stored visibility: %v", err) + } + if dbVis != "workspace" { + t.Fatalf("stored visibility = %q, want workspace", dbVis) + } +}