Files
multica/server/internal/handler/runtime_visibility_test.go
T
6db6b235ba MUL-6126: fix(runtime): make private runtimes owner-only in the API and CLI too (#6905)
* MUL-6126: fix(runtime): make private runtimes owner-only in the API and CLI too

The web UI has always disabled another member's private runtime in every
runtime picker, but canUseRuntimeForAgent let a workspace owner/admin bind
an agent to it anyway, so the same action succeeded over the API and CLI and
failed in the UI (#6898).

Unify on the stricter rule the clients already show: a private runtime is
usable only by its owner, a public one by anyone in the workspace, and
workspace role plays no part. A private runtime is someone's own machine —
running an agent there spends their credentials and their files, which is
not an administrative decision. Admins keep canEditRuntime, so opening a
runtime up stays possible by flipping it to public, an explicit and visible
act instead of a silent bypass.

Drops the role branch from canUseRuntimeForAgent, which every bind path
already shares (create, update, builder create/switch, Mika, onboarding),
and retires the "or a workspace admin" half of the 403s and the matching UI
copy and docs in all four locales. On the client the inspector runtime
picker now calls the shared isRuntimeUsableForUser instead of re-deriving
the rule inline, so there is one predicate per side and they agree.

Existing agents already bound to another member's private runtime keep
running; only new binds and re-binds are refused. An ownerless private
runtime is now refused up front rather than at task claim, where it already
failed for want of an owner to mint the agent token (MUL-3292).

Co-authored-by: multica-agent <[email protected]>

* MUL-6126: test(server): own the integration fixture runtime

The router integration fixture registered its runtime with no owner_id, so
it relied on the workspace-owner override that this branch removes: every
agent these tests create through POST /api/agents now 403s on a private,
ownerless runtime.

Give the fixture runtime to the fixture user, matching the handler test
fixture and every runtime a real daemon registers with a member credential.
The gate is what changed; ownerless was never the shape under test.

Co-authored-by: multica-agent <[email protected]>

* MUL-6126: fix(runtime): let only the owner share a runtime, and refuse ownerless binds

Two holes in the owner-only rule this branch introduced, both found in review.

canEditRuntime still let a workspace admin flip someone else's runtime to
public, which is the old override with one extra request in front of it: the
admin publishes the member's machine to the whole workspace, binds, and can
flip it back, with no owner consent anywhere in the loop. Visibility now has
its own owner-only gate (canSetRuntimeVisibility) while canEditRuntime keeps
covering rename and delete, which hand out nobody's credentials. A no-op
resubmit of the unchanged value is still tolerated so an admin renaming a
runtime through a PATCH-as-PUT client doesn't start 403ing. The runtime detail
page follows: the Private/Public editor is the owner's, everyone else gets the
read-only chip, whose tooltip now speaks in the third person because admins
read it too.

The bind gate also let an ownerless runtime through whenever it was public.
Nothing can run there — task claim needs an owner to mint the agent's task
token and cancels the task without one (MUL-3292) — so binding produced agents
that failed at their first task. Both predicates now check for an owner first,
server and client alike.

Docs, UI copy and the RuntimeVisibility type comment drop the last claims that
an admin may bind or share a private runtime.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:55:09 +08:00

590 lines
23 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestCanUseRuntimeForAgent_Pure exercises the pure predicate behind the
// CreateAgent / UpdateAgent runtime gate. The truth table is the single
// product contract shared with the clients (MUL-6126): a private runtime is
// usable only by its owner — workspace owners and admins included, since a
// private runtime is another member's own machine — while a public runtime is
// usable by anyone in the workspace. An ownerless runtime is usable by nobody,
// public included: it cannot run tasks either (task claim requires a runtime
// owner to mint the agent token, MUL-3292), so the gate refuses it up front
// instead of letting the binding succeed and every task on it fail.
func TestCanUseRuntimeForAgent_Pure(t *testing.T) {
ownerUserID := "11111111-1111-1111-1111-111111111111"
otherUserID := "22222222-2222-2222-2222-222222222222"
privateRT := db.AgentRuntime{
OwnerID: util.MustParseUUID(ownerUserID),
Visibility: "private",
}
publicRT := db.AgentRuntime{
OwnerID: util.MustParseUUID(ownerUserID),
Visibility: "public",
}
ownerlessPrivateRT := db.AgentRuntime{Visibility: "private"}
ownerlessPublicRT := db.AgentRuntime{Visibility: "public"}
cases := []struct {
name string
userID string
role string
rt db.AgentRuntime
want bool
}{
// no workspace owner / admin override: role never widens access
{"workspace owner on private runtime owned by another", otherUserID, "owner", privateRT, false},
{"workspace admin on private runtime owned by another", otherUserID, "admin", privateRT, false},
{"workspace admin on someone else's public runtime", otherUserID, "admin", publicRT, true},
// runtime owner
{"runtime owner on own private runtime", ownerUserID, "member", privateRT, true},
{"runtime owner on own public runtime", ownerUserID, "member", publicRT, true},
{"runtime owner who is also a workspace admin", ownerUserID, "admin", privateRT, true},
// public runtime allows anyone in workspace
{"plain member on someone else's public runtime", otherUserID, "member", publicRT, true},
// private runtime owned by someone else is denied to everyone
{"plain member on someone else's private runtime", otherUserID, "member", privateRT, false},
{"plain member with empty role on private runtime", otherUserID, "", privateRT, false},
// ownerless runtimes are refused whatever their visibility — nobody
// can be issued a task token for them (MUL-3292)
{"workspace owner on an ownerless private runtime", otherUserID, "owner", ownerlessPrivateRT, false},
{"plain member on an ownerless public runtime", otherUserID, "member", ownerlessPublicRT, false},
{"workspace admin on an ownerless public runtime", otherUserID, "admin", ownerlessPublicRT, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
member := db.Member{
UserID: util.MustParseUUID(tc.userID),
Role: tc.role,
}
got := canUseRuntimeForAgent(member, tc.rt)
if got != tc.want {
t.Fatalf("canUseRuntimeForAgent(role=%s, visibility=%s, owner=%s, caller=%s) = %v; want %v",
tc.role, tc.rt.Visibility, ownerUserID, tc.userID, got, tc.want)
}
})
}
}
// runtimeVisibilityFixture builds the three-actor world the gate needs to
// exercise: a private runtime owned by a non-admin member, a separate plain
// member in the same workspace, and the workspace owner (testUserID). The
// runtime is registered through agent_runtime directly so the test doesn't
// depend on the daemon-registration code path. Returns runtime id, runtime
// owner user id, and the plain member's user id.
func runtimeVisibilityFixture(t *testing.T) (runtimeID, runtimeOwnerID, plainMemberID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Runtime Owner', '[email protected]')
RETURNING id
`).Scan(&runtimeOwnerID); err != nil {
t.Fatalf("create runtime owner user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = '[email protected]'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, runtimeOwnerID); err != nil {
t.Fatalf("add runtime owner as member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Plain Runtime Member', '[email protected]')
RETURNING id
`).Scan(&plainMemberID); err != nil {
t.Fatalf("create plain member user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = '[email protected]'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, plainMemberID); err != nil {
t.Fatalf("add plain member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, visibility, last_seen_at
)
VALUES ($1, NULL, 'Visibility Test Runtime', 'cloud', 'visibility_test_provider', 'online', 'visibility test', '{}'::jsonb, $2, 'private', now())
RETURNING id
`, testWorkspaceID, runtimeOwnerID).Scan(&runtimeID); err != nil {
t.Fatalf("create runtime: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
})
return runtimeID, runtimeOwnerID, plainMemberID
}
// TestCreateAgent_RejectsPrivateRuntimeForNonOwner walks the gate end-to-end:
// the runtime is private and owned by a plain member, so only that owner can
// create agents on it. Neither a plain workspace member nor the workspace
// owner may — the admin override is gone (MUL-6126), which is what keeps the
// API/CLI answer identical to the disabled runtime row the web UI renders.
func TestCreateAgent_RejectsPrivateRuntimeForNonOwner(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, plainMemberID := runtimeVisibilityFixture(t)
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM agent WHERE workspace_id = $1 AND name LIKE 'runtime-visibility-test-%'`,
testWorkspaceID)
})
body := func(name string) map[string]any {
return map[string]any{
"name": name,
"description": "",
"runtime_id": runtimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
}
}
// Workspace owner (testUserID): refused. This is the API/CLI half of
// MUL-6126 — the web UI has always disabled this row, and an admin who
// needs the machine flips its visibility to public instead.
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body("runtime-visibility-test-admin")))
if w.Code != http.StatusForbidden {
t.Fatalf("CreateAgent as workspace owner on another member's private runtime: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Runtime owner: allowed because they own the runtime.
w = httptest.NewRecorder()
testHandler.CreateAgent(w, newRequestAs(runtimeOwnerID, http.MethodPost, "/api/agents", body("runtime-visibility-test-runtime-owner")))
if w.Code != http.StatusCreated {
t.Fatalf("CreateAgent as runtime owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Plain member: this is the hole MUL-2062 closes — must be 403.
w = httptest.NewRecorder()
testHandler.CreateAgent(w, newRequestAs(plainMemberID, http.MethodPost, "/api/agents", body("runtime-visibility-test-plain-member")))
if w.Code != http.StatusForbidden {
t.Fatalf("CreateAgent as plain member on private runtime: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateAgent_AllowsPublicRuntimeForPlainMember verifies the "public"
// half of the visibility predicate: once the runtime owner flips it to
// public, any workspace member can create agents on it.
func TestCreateAgent_AllowsPublicRuntimeForPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, _, plainMemberID := runtimeVisibilityFixture(t)
ctx := context.Background()
if _, err := testPool.Exec(ctx,
`UPDATE agent_runtime SET visibility = 'public' WHERE id = $1`, runtimeID,
); err != nil {
t.Fatalf("flip runtime to public: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM agent WHERE workspace_id = $1 AND name = 'runtime-visibility-test-public-runtime'`,
testWorkspaceID)
})
body := map[string]any{
"name": "runtime-visibility-test-public-runtime",
"description": "",
"runtime_id": runtimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequestAs(plainMemberID, http.MethodPost, "/api/agents", body))
if w.Code != http.StatusCreated {
t.Fatalf("CreateAgent as plain member on public runtime: expected 201, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAgent_RejectsRebindToPrivateRuntime is the regression for the
// "update can bypass create" backdoor — without this gate a plain member
// could create an agent on a public runtime, then re-bind it onto someone
// else's private runtime via UpdateAgent.
func TestUpdateAgent_RejectsRebindToPrivateRuntime(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
privateRuntimeID, _, plainMemberID := runtimeVisibilityFixture(t)
ctx := context.Background()
// Create a public runtime that the plain member can legitimately own
// an agent on, then we try to move the agent onto the private runtime.
var publicRuntimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, visibility, last_seen_at
)
VALUES ($1, NULL, 'Public Runtime', 'cloud', 'visibility_test_public_provider', 'online', 'public', '{}'::jsonb, $2, 'public', now())
RETURNING id
`, testWorkspaceID, plainMemberID).Scan(&publicRuntimeID); err != nil {
t.Fatalf("create public runtime: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, publicRuntimeID)
})
var agentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args
)
VALUES ($1, 'rebind-test-agent', '', 'cloud', '{}'::jsonb,
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, testWorkspaceID, publicRuntimeID, plainMemberID).Scan(&agentID); err != nil {
t.Fatalf("create agent on public runtime: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
body := map[string]any{
"runtime_id": privateRuntimeID,
}
w := httptest.NewRecorder()
req := newRequestAs(plainMemberID, http.MethodPut, "/api/agents/"+agentID, body)
req = withURLParam(req, "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UpdateAgent rebinding to private runtime: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAgent_WorkspaceAdminCannotRebindToMemberPrivateRuntime walks the
// whole MUL-6126 contract as one story: a workspace owner cannot move an agent
// onto a member's private runtime, cannot open that runtime up either, and
// only once the runtime's own owner shares it does the same rebind go through.
// Together those three steps are what makes owner-only real — a visibility
// flip the admin could perform themselves would just be the old override with
// an extra request.
func TestUpdateAgent_WorkspaceAdminCannotRebindToMemberPrivateRuntime(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
memberPrivateRuntimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
ctx := context.Background()
// The admin's own agent, on the fixture runtime they own — so the only
// thing this update can trip over is the runtime gate.
var agentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args
)
VALUES ($1, 'admin-rebind-test-agent', '', 'cloud', '{}'::jsonb,
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, testWorkspaceID, testRuntimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("create agent on the workspace owner's runtime: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
rebind := func() *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := newRequest(http.MethodPut, "/api/agents/"+agentID, map[string]any{
"runtime_id": memberPrivateRuntimeID,
})
testHandler.UpdateAgent(w, withURLParam(req, "id", agentID))
return w
}
setVisibility := func(actorID, visibility string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+memberPrivateRuntimeID, map[string]any{
"visibility": visibility,
})
testHandler.UpdateAgentRuntime(w, withURLParam(req, "runtimeId", memberPrivateRuntimeID))
return w
}
if w := rebind(); w.Code != http.StatusForbidden {
t.Fatalf("UpdateAgent as workspace owner onto a member's private runtime: expected 403, got %d: %s",
w.Code, w.Body.String())
}
// …and cannot route around it by publishing the member's machine itself.
if w := setVisibility(testUserID, "public"); w.Code != http.StatusForbidden {
t.Fatalf("UpdateAgentRuntime as workspace owner on a member's runtime: expected 403, got %d: %s",
w.Code, w.Body.String())
}
// The runtime's owner shares it deliberately.
if w := setVisibility(runtimeOwnerID, "public"); w.Code != http.StatusOK {
t.Fatalf("UpdateAgentRuntime as runtime owner → public: expected 200, got %d: %s",
w.Code, w.Body.String())
}
if w := rebind(); w.Code != http.StatusOK {
t.Fatalf("UpdateAgent onto the same runtime once public: expected 200, got %d: %s",
w.Code, w.Body.String())
}
}
// TestCanSetRuntimeVisibility_Pure pins the narrower gate: only the runtime
// owner may flip private↔public. Workspace owners/admins keep canEditRuntime
// for rename and delete, but sharing a machine is the owner's call — otherwise
// the MUL-6126 bind rule would be one PATCH away from being bypassed.
func TestCanSetRuntimeVisibility_Pure(t *testing.T) {
ownerUserID := "11111111-1111-1111-1111-111111111111"
otherUserID := "22222222-2222-2222-2222-222222222222"
ownedRT := db.AgentRuntime{
OwnerID: util.MustParseUUID(ownerUserID),
Visibility: "private",
}
ownerlessRT := db.AgentRuntime{Visibility: "private"}
cases := []struct {
name string
userID string
role string
rt db.AgentRuntime
want bool
}{
{"runtime owner", ownerUserID, "member", ownedRT, true},
{"workspace owner who does not own the runtime", otherUserID, "owner", ownedRT, false},
{"workspace admin who does not own the runtime", otherUserID, "admin", ownedRT, false},
{"plain member", otherUserID, "member", ownedRT, false},
{"workspace owner on an ownerless runtime", otherUserID, "owner", ownerlessRT, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
member := db.Member{
UserID: util.MustParseUUID(tc.userID),
Role: tc.role,
}
if got := canSetRuntimeVisibility(member, tc.rt); got != tc.want {
t.Fatalf("canSetRuntimeVisibility(role=%s, caller=%s) = %v; want %v",
tc.role, tc.userID, got, tc.want)
}
})
}
}
// TestUpdateAgentRuntime_VisibilityPatchApplies pins the invariant that
// a PATCH carrying `visibility` correctly updates the runtime.
func TestUpdateAgentRuntime_VisibilityPatchApplies(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
w := httptest.NewRecorder()
req := newRequestAs(runtimeOwnerID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
"visibility": "public",
})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PATCH visibility: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp AgentRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Visibility != "public" {
t.Fatalf("visibility patch: got %q, want public", resp.Visibility)
}
}
// TestUpdateAgentRuntime_IgnoresTimezoneField guards the RFC migration that
// dropped `timezone` from UpdateAgentRuntimeRequest: a PATCH body still
// carrying `timezone` must not error, must not echo a `timezone` key back,
// and must still apply the recognised `visibility` field. Timezone is now a
// user-level preference, not a per-runtime one.
func TestUpdateAgentRuntime_IgnoresTimezoneField(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
w := httptest.NewRecorder()
// NOTE: visibility is "public" (not "workspace"): the runtime visibility
// enum is private|public — "workspace" would 400 before any mutation,
// which would not exercise the "visibility still applied" assertion.
req := newRequestAs(runtimeOwnerID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
"timezone": "Asia/Tokyo",
"visibility": "public",
})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PATCH with stray timezone: expected 200, got %d: %s", w.Code, w.Body.String())
}
// The response must carry no `timezone` key — runtimes have no such field.
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, present := raw["timezone"]; present {
t.Errorf("response unexpectedly contains a timezone key: %s", w.Body.String())
}
// `visibility` was still applied.
var resp AgentRuntimeResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Visibility != "public" {
t.Errorf("visibility patch: got %q, want public", resp.Visibility)
}
}
// TestUpdateAgentRuntime_InvalidVisibilityReturns400 verifies that an invalid
// visibility value is rejected with 400 before any mutation runs.
func TestUpdateAgentRuntime_InvalidVisibilityReturns400(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
w := httptest.NewRecorder()
req := newRequestAs(runtimeOwnerID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
"visibility": "everyone",
})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("PATCH with invalid visibility: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAgentRuntime_VisibilityToggle covers the PATCH endpoint: the
// runtime owner can flip private↔public; nobody else can — a workspace owner
// is refused exactly like a plain member (MUL-6126) — and an unknown value is
// rejected with 400.
func TestUpdateAgentRuntime_VisibilityToggle(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, plainMemberID := runtimeVisibilityFixture(t)
patch := func(actorID string, visibility string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
"visibility": visibility,
})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
return w
}
// Runtime owner flips private → public.
if w := patch(runtimeOwnerID, "public"); w.Code != http.StatusOK {
t.Fatalf("UpdateAgentRuntime as runtime owner → public: expected 200, got %d: %s", w.Code, w.Body.String())
} else {
var resp AgentRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Visibility != "public" {
t.Fatalf("expected visibility=public, got %q", resp.Visibility)
}
}
// Workspace owner (testUserID) cannot flip it back: canEditRuntime still
// admits them to this endpoint for rename/delete, and the visibility field
// alone is what refuses them.
if w := patch(testUserID, "private"); w.Code != http.StatusForbidden {
t.Fatalf("UpdateAgentRuntime as workspace owner → private: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Its owner can, and the runtime is back to private for the rest of the case.
if w := patch(runtimeOwnerID, "private"); w.Code != http.StatusOK {
t.Fatalf("UpdateAgentRuntime as runtime owner → private: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Plain member: forbidden, regardless of intent.
if w := patch(plainMemberID, "public"); w.Code != http.StatusForbidden {
t.Fatalf("UpdateAgentRuntime as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Bad value from the owner: 400.
if w := patch(runtimeOwnerID, "everyone"); w.Code != http.StatusBadRequest {
t.Fatalf("UpdateAgentRuntime with invalid visibility: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAgentRuntime_AdminMayEchoUnchangedVisibility guards the edge the
// owner-only gate must not break: an admin renaming someone else's runtime
// through a PATCH-as-PUT client that resends the current visibility is not
// trying to share that machine, so the no-op is dropped rather than turned
// into a 403. Only a real change is refused.
func TestUpdateAgentRuntime_AdminMayEchoUnchangedVisibility(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, _, _ := runtimeVisibilityFixture(t)
w := httptest.NewRecorder()
req := newRequest(http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
"visibility": "private", // unchanged — the fixture runtime is private
"custom_name": "Renamed By Admin",
})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("admin rename echoing unchanged visibility: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp AgentRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.CustomName == nil || *resp.CustomName != "Renamed By Admin" {
t.Errorf("custom name was not applied: %v", resp.CustomName)
}
if resp.Visibility != "private" {
t.Errorf("visibility drifted: got %q, want private", resp.Visibility)
}
}