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>
This commit is contained in:
yushen
2026-06-16 13:05:55 +08:00
parent 8b68ef508f
commit 8d6b7663df
9 changed files with 74 additions and 151 deletions

View File

@@ -287,16 +287,11 @@
"base_family": "Base protocol family",
"command": "Command",
"description": "Description",
"visibility": "Visibility",
"no_description": "No description",
"edit": "Edit",
"delete": "Delete",
"select_hint": "Select a runtime to see its details."
},
"visibility": {
"workspace": "Workspace",
"private": "Private"
},
"form": {
"create_title": "New custom runtime",
"edit_title": "Edit custom runtime",
@@ -311,9 +306,6 @@
"command_name_placeholder": "claude",
"description_label": "Description",
"description_placeholder": "Optional — what this runtime is for",
"visibility_label": "Visibility",
"visibility_workspace_hint": "Any workspace member can use this runtime.",
"visibility_private_hint": "Only you and workspace admins can use this runtime.",
"error_display_name_required": "Display name is required.",
"error_command_required": "Command is required.",
"back": "Back",

View File

@@ -275,16 +275,11 @@
"base_family": "ベースプロトコルファミリー",
"command": "コマンド",
"description": "説明",
"visibility": "公開範囲",
"no_description": "説明なし",
"edit": "編集",
"delete": "削除",
"select_hint": "ランタイムを選択すると詳細が表示されます。"
},
"visibility": {
"workspace": "ワークスペース",
"private": "プライベート"
},
"form": {
"create_title": "新しいカスタムランタイム",
"edit_title": "カスタムランタイムを編集",
@@ -299,9 +294,6 @@
"command_name_placeholder": "claude",
"description_label": "説明",
"description_placeholder": "任意 — このランタイムの用途",
"visibility_label": "公開範囲",
"visibility_workspace_hint": "ワークスペースの全メンバーがこのランタイムを使用できます。",
"visibility_private_hint": "あなたとワークスペース管理者のみがこのランタイムを使用できます。",
"error_display_name_required": "表示名は必須です。",
"error_command_required": "コマンドは必須です。",
"back": "戻る",

View File

@@ -287,16 +287,11 @@
"base_family": "기본 프로토콜 제품군",
"command": "명령",
"description": "설명",
"visibility": "공개 범위",
"no_description": "설명 없음",
"edit": "편집",
"delete": "삭제",
"select_hint": "런타임을 선택하면 세부 정보가 표시됩니다."
},
"visibility": {
"workspace": "워크스페이스",
"private": "비공개"
},
"form": {
"create_title": "새 사용자 지정 런타임",
"edit_title": "사용자 지정 런타임 편집",
@@ -311,9 +306,6 @@
"command_name_placeholder": "claude",
"description_label": "설명",
"description_placeholder": "선택 사항 — 이 런타임의 용도",
"visibility_label": "공개 범위",
"visibility_workspace_hint": "모든 워크스페이스 구성원이 이 런타임을 사용할 수 있습니다.",
"visibility_private_hint": "나와 워크스페이스 관리자만 이 런타임을 사용할 수 있습니다.",
"error_display_name_required": "표시 이름은 필수입니다.",
"error_command_required": "명령은 필수입니다.",
"back": "뒤로",

View File

@@ -275,16 +275,11 @@
"base_family": "基础协议类型",
"command": "命令",
"description": "描述",
"visibility": "可见性",
"no_description": "无描述",
"edit": "编辑",
"delete": "删除",
"select_hint": "选择一个运行时以查看详情。"
},
"visibility": {
"workspace": "工作区",
"private": "私有"
},
"form": {
"create_title": "新建自定义运行时",
"edit_title": "编辑自定义运行时",
@@ -299,9 +294,6 @@
"command_name_placeholder": "claude",
"description_label": "描述",
"description_placeholder": "可选 — 此运行时的用途",
"visibility_label": "可见性",
"visibility_workspace_hint": "任何工作区成员都可以使用此运行时。",
"visibility_private_hint": "只有你和工作区管理员可以使用此运行时。",
"error_display_name_required": "显示名称为必填项。",
"error_command_required": "命令为必填项。",
"back": "返回",

View File

@@ -15,7 +15,6 @@ import { useQuery } from "@tanstack/react-query";
import { ApiError } from "@multica/core/api";
import type {
RuntimeProfile,
RuntimeProfileVisibility,
RuntimeProtocolFamily,
} from "@multica/core/types";
import {
@@ -365,11 +364,6 @@ function DetailPanel({
</span>
)}
</DetailRow>
<DetailRow label={t(($) => $.profiles.detail.visibility)}>
{profile.visibility === "private"
? t(($) => $.profiles.visibility.private)
: t(($) => $.profiles.visibility.workspace)}
</DetailRow>
</dl>
</div>
@@ -536,9 +530,6 @@ function ProfileDetailsForm({
commandName: profile?.command_name ?? "",
description: profile?.description ?? "",
});
const [visibility, setVisibility] = useState<RuntimeProfileVisibility>(
profile?.visibility ?? "workspace",
);
const [errors, setErrors] = useState<ProfileFormErrorField[]>([]);
// Server-side error surfaced under the display-name field (duplicate) or
// as a generic banner.
@@ -567,7 +558,6 @@ function ProfileDetailsForm({
protocol_family: family,
command_name: values.commandName.trim(),
...(description ? { description } : {}),
visibility,
});
toast.success(t(($) => $.profiles.form.toast_created));
onSaved(created);
@@ -578,7 +568,6 @@ function ProfileDetailsForm({
display_name: values.displayName.trim(),
command_name: values.commandName.trim(),
description: description ? description : null,
visibility,
},
});
toast.success(t(($) => $.profiles.form.toast_updated));
@@ -712,25 +701,12 @@ function ProfileDetailsForm({
it's wired end-to-end. See TODO(MUL-3284) in
server/internal/daemon/daemon.go. */}
<fieldset className="space-y-1.5">
<legend className="text-xs text-muted-foreground">
{t(($) => $.profiles.form.visibility_label)}
</legend>
<div className="flex gap-2">
<VisibilityOption
active={visibility === "workspace"}
label={t(($) => $.profiles.visibility.workspace)}
hint={t(($) => $.profiles.form.visibility_workspace_hint)}
onClick={() => setVisibility("workspace")}
/>
<VisibilityOption
active={visibility === "private"}
label={t(($) => $.profiles.visibility.private)}
hint={t(($) => $.profiles.form.visibility_private_hint)}
onClick={() => setVisibility("private")}
/>
</div>
</fieldset>
{/* NOTE: a visibility control is intentionally omitted in v1. The
server forces every profile to 'workspace' because the read paths
(list, daemon pull, register) do not yet enforce 'private', so
offering a private toggle would leak the profile to other members.
Re-add once creator-visibility filtering exists. Follow-up:
MUL-3308. */}
{formError && (
<p role="alert" className="text-xs text-destructive">
@@ -763,34 +739,3 @@ function ProfileDetailsForm({
</div>
);
}
function VisibilityOption({
active,
label,
hint,
onClick,
}: {
active: boolean;
label: string;
hint: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="radio"
aria-checked={active}
onClick={onClick}
title={hint}
className={cn(
"flex-1 rounded-md border px-3 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
active ? "border-primary bg-accent" : "bg-background hover:bg-accent/50",
)}
>
<span className="font-medium">{label}</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground">
{hint}
</span>
</button>
);
}

View File

@@ -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"),
})
}

View File

@@ -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"])
}
}

View File

@@ -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}
}

View File

@@ -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)
}
}