mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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": "戻る",
|
||||
|
||||
@@ -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": "뒤로",
|
||||
|
||||
@@ -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": "返回",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user