From ff674c4e1df2d63185a45d724a6a5cc0cdb4da05 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:28:22 +0800 Subject: [PATCH] MUL-8185: Support sharing runtimes with specific members (#9455) * feat: share runtimes with selected workspace members Co-authored-by: multica-agent * refactor(runtimes): one-click private/public and popover member picker Private and Public apply immediately again (restrictions still go through the reviewed revocation dialog). Specific members opens a draft popover with search, Cancel and Save; selected recipients are summarized under the scope control with an Edit entry point. The editor moves to its own runtime-access-editor component. Co-authored-by: multica-agent --------- Co-authored-by: multica-agent --- apps/docs/content/docs/daemon-runtimes.mdx | 6 +- apps/docs/content/docs/daemon-runtimes.zh.mdx | 6 +- e2e/fixtures.ts | 17 + e2e/runtime-sharing.spec.ts | 60 ++++ packages/core/api/client.ts | 12 +- packages/core/runtimes/access.test.ts | 10 + packages/core/runtimes/access.ts | 7 +- packages/core/runtimes/mutations.ts | 6 +- packages/core/runtimes/schema.test.ts | 18 + packages/core/runtimes/schema.ts | 42 +++ packages/core/types/agent.ts | 6 +- packages/views/locales/en/runtimes.json | 29 +- packages/views/locales/fr/runtimes.json | 29 +- packages/views/locales/ja/runtimes.json | 28 +- packages/views/locales/ko/runtimes.json | 28 +- packages/views/locales/zh-Hans/runtimes.json | 28 +- .../revoke-runtime-access-dialog.test.tsx | 10 +- .../revoke-runtime-access-dialog.tsx | 3 + .../components/runtime-access-editor.tsx | 297 +++++++++++++++++ .../runtime-detail-visibility.test.tsx | 95 +++++- .../runtimes/components/runtime-detail.tsx | 153 +-------- .../runtimes/components/runtime-list.tsx | 11 +- server/internal/handler/agent.go | 20 +- server/internal/handler/agent_builder.go | 4 +- server/internal/handler/daemon.go | 8 +- server/internal/handler/runtime.go | 311 +++++++----------- server/internal/handler/runtime_sharing.go | 148 +++++++++ .../internal/handler/runtime_sharing_test.go | 208 ++++++++++++ server/internal/handler/workspace_revoke.go | 51 ++- server/internal/service/agent_ready.go | 6 +- server/internal/service/runtime_access.go | 15 + .../internal/service/runtime_access_test.go | 35 ++ .../592_runtime_shared_with.down.sql | 1 + .../migrations/592_runtime_shared_with.up.sql | 2 + server/pkg/db/generated/agent.sql.go | 25 +- server/pkg/db/generated/models.go | 1 + server/pkg/db/generated/runtime.sql.go | 229 ++++++++++--- server/pkg/db/queries/agent.sql | 11 +- server/pkg/db/queries/runtime.sql | 47 ++- 39 files changed, 1525 insertions(+), 498 deletions(-) create mode 100644 e2e/runtime-sharing.spec.ts create mode 100644 packages/core/runtimes/schema.test.ts create mode 100644 packages/core/runtimes/schema.ts create mode 100644 packages/views/runtimes/components/runtime-access-editor.tsx create mode 100644 server/internal/handler/runtime_sharing.go create mode 100644 server/internal/handler/runtime_sharing_test.go create mode 100644 server/internal/service/runtime_access.go create mode 100644 server/internal/service/runtime_access_test.go create mode 100644 server/migrations/592_runtime_shared_with.down.sql create mode 100644 server/migrations/592_runtime_shared_with.up.sql diff --git a/apps/docs/content/docs/daemon-runtimes.mdx b/apps/docs/content/docs/daemon-runtimes.mdx index 8b68d05b8..da8569e26 100644 --- a/apps/docs/content/docs/daemon-runtimes.mdx +++ b/apps/docs/content/docs/daemon-runtimes.mdx @@ -109,13 +109,15 @@ One daemon executes at most 20 runs at a time by default; each agent at most 6. Once a limit is reached, new runs keep queueing. You can adjust a single agent's concurrency in the agent settings, and the machine-wide cap through `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`. Parallel runs compete for machine capacity, tool account quota, and the same working directory all at once. -## Private and public runtimes +## Runtime sharing A local runtime is **private** by default: only the runtime owner can create agents on it. Workspace owners and admins are no exception — the runtime is someone else's computer, and running an agent there spends their machine and their tool credentials. Only the runtime owner can make it **public** — workspace admins can rename or delete a runtime, but sharing one is the owner's decision. Other workspace members can then select this runtime as well; that does not share the underlying AI coding tool's login credentials — it only lets members route their agents' runs to this computer. -When the owner makes a public runtime **private** again, Multica shows the affected agents and active runs, including Agent Builder sessions, before applying the change. Confirmation unbinds those teammates' agents, cancels their queued, dispatched, running, local-directory-waiting and deferred runs with `runtime_access_revoked`, and pauses active autopilots assigned to the unbound agents. The owner's own agents and runs are unaffected. A changed plan requires renewed confirmation; concurrent chat writes may require a retry. To resume work, bind the agent to an accessible runtime, then explicitly resume its paused autopilots. +To share with selected people, open the runtime's detail page, select **Specific members**, choose one or more workspace members, and **Save**. Those members can select the runtime and create agents on it. Only its owner can change the recipient list; recipients do not gain permission to rename, delete, or administer the runtime. + +When the owner removes members, limits a public runtime to specific members, or makes it **private** again, Multica shows the affected agents and active runs, including Agent Builder sessions, before applying the change. Confirmation unbinds those teammates' agents, cancels their queued, dispatched, running, local-directory-waiting and deferred runs with `runtime_access_revoked`, and pauses active autopilots assigned to the unbound agents. Agents and runs belonging to the owner or members who retain access are unaffected. A changed plan requires renewed confirmation; concurrent chat writes may require a retry. To resume work, bind the agent to an accessible runtime, then explicitly resume its paused autopilots. Cancellation preserves chat history and stores draft/attachment recovery data on the server. Web and desktop use the existing draft recovery UI; mobile does not yet expose that recovery UI. A started turn with no output waits for the daemon's cancellation acknowledgement or the server's finalize grace period before deciding whether to restore the input. A connected daemon observes cancellation through its regular check. If the computer is offline, server-side access is still revoked immediately, but the local process may continue until it receives cancellation after reconnecting; the server cannot guarantee an immediate remote stop. diff --git a/apps/docs/content/docs/daemon-runtimes.zh.mdx b/apps/docs/content/docs/daemon-runtimes.zh.mdx index 5c74d1094..eb3154557 100644 --- a/apps/docs/content/docs/daemon-runtimes.zh.mdx +++ b/apps/docs/content/docs/daemon-runtimes.zh.mdx @@ -109,13 +109,15 @@ multica daemon start 达到上限后,新运行会继续排队。你可以在智能体设置中调整单个智能体的并发数,也可以通过 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 调整整台电脑的上限。并行执行会同时占用机器性能、工具账号额度和同一工作目录。 -## 私有与公开 +## 运行时共享 本地运行时默认是**私有**的:只有运行时所有者可以在它上面创建智能体。工作区的 owner、admin 也不例外——运行时是别人的电脑,在上面跑智能体消耗的是对方的机器和工具凭据。 只有运行时所有者可以把它改为**公开**——管理员可以重命名或删除运行时,但要不要共享由所有者决定。公开后,工作区中的其他成员也可以选择这条运行时;这不会分享底层 AI 编程工具的登录凭据,只是允许成员把智能体的执行路由到这台电脑。 -所有者把公开运行时重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者自己的智能体和运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。 +如需仅向部分成员开放,在运行时详情中选择**指定成员**,勾选一位或多位工作区成员,然后**保存**。所选成员可以选择该运行时并创建智能体。只有所有者能修改共享名单;获得共享权限不会同时获得重命名、删除或管理运行时的权限。 + +所有者移除共享成员、将公开范围改为指定成员,或重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者和仍有权限成员的智能体与运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。 取消会保留聊天历史,并在服务端保存草稿和附件恢复数据。网页端和桌面端沿用现有的草稿恢复入口;移动端暂未提供该入口。已开始但尚无输出的运行,会等待守护进程确认取消或服务端收尾宽限期结束,再决定是否恢复输入。已连接的守护进程通过常规检查接收取消。电脑离线时,服务端仍会立即撤销访问,但本地进程可能继续执行,直到重新连接并收到取消;服务端无法保证立即远程停止。 diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 9f0312abf..b9ff5dba4 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -346,6 +346,23 @@ export class TestApiClient { return (await res.json()) as { id: string }; } + /** Seed an owned runtime without launching an actual agent tool. */ + async seedRuntime(name: string): Promise { + if (!this.workspaceId || !this.email) throw new Error("Select a workspace before seeding a runtime"); + const client = new pg.Client(DATABASE_URL); + await client.connect(); + try { + const result = await client.query( + `INSERT INTO agent_runtime (workspace_id, name, runtime_mode, provider, status, owner_id, last_seen_at) + SELECT $1, $2, 'local', 'claude', 'online', id, now() FROM "user" WHERE email = $3 RETURNING id`, + [this.workspaceId, name, this.email], + ); + return result.rows[0].id as string; + } finally { + await client.end(); + } + } + /** Adds an existing user to this client's workspace and returns the member id. */ async addMemberByEmail(email: string, role: "admin" | "member" = "member"): Promise { if (!this.workspaceId) throw new Error("Cannot add a member before a workspace is selected"); diff --git a/e2e/runtime-sharing.spec.ts b/e2e/runtime-sharing.spec.ts new file mode 100644 index 000000000..310671bee --- /dev/null +++ b/e2e/runtime-sharing.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; +import { TestApiClient } from "./fixtures"; + +test("runtime owner can save specific members and revoke their access", async ({ page }, testInfo) => { + const api = new TestApiClient(); + const member = new TestApiClient(); + const run = `${Date.now()}-${process.pid}`; + await api.login(`runtime-owner-${run}@localhost`, "Runtime Owner"); + const workspace = await api.ensureWorkspace("Runtime sharing", `runtime-sharing-${run}`); + await api.markUserOnboarded(); + try { + await member.login(`runtime-member-${run}@localhost`, "Alice"); + const memberId = await api.addMemberByEmail(`runtime-member-${run}@localhost`); + const runtimeId = await api.seedRuntime("Studio Mac"); + await page.addInitScript((token) => { + localStorage.setItem("multica_token", token!); + localStorage.setItem("multica:chat:isOpen", "false"); + }, api.getToken()); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/${workspace.slug}/runtimes/${runtimeId}`); + await page.getByRole("row", { name: /Studio Mac Built-in/ }).click(); + const scope = (name: string) => page.getByRole("button", { name, exact: true }); + const patched = () => page.waitForResponse((response) => response.url().endsWith(`/api/runtimes/${runtimeId}`) && response.request().method() === "PATCH"); + await expect(scope("Private")).toHaveAttribute("aria-pressed", "true"); + await page.screenshot({ path: testInfo.outputPath("runtime-private.png"), fullPage: true }); + + // Choosing members opens a draft picker; nothing persists until Save. + await scope("Members").click(); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await page.getByRole("checkbox", { name: "Alice" }).check(); + await page.screenshot({ path: testInfo.outputPath("runtime-member-picker.png"), fullPage: true }); + const saved = patched(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + expect((await saved).status()).toBe(200); + await page.reload(); + await expect(scope("Members")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("button", { name: /Alice.*Edit/ })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("runtime-shared.png"), fullPage: true }); + + // Private applies in one click. + const revoked = patched(); + await scope("Private").click(); + expect((await revoked).status()).toBe(200); + await page.reload(); + await expect(scope("Private")).toHaveAttribute("aria-pressed", "true"); + + await scope("Members").click(); + await page.getByRole("checkbox", { name: "Alice" }).check(); + const sharedAgain = patched(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + expect((await sharedAgain).status()).toBe(200); + await api.removeMember(memberId); + await expect(scope("Private")).toHaveAttribute("aria-pressed", "true", { timeout: 15000 }); + await api.addMemberByEmail(`runtime-member-${run}@localhost`); + await page.reload(); + await expect(scope("Private")).toHaveAttribute("aria-pressed", "true"); + } finally { + await api.deleteWorkspace(); + } +}); diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 116e07d28..8ab0ab4c6 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -1,4 +1,5 @@ import type { ZodType } from "zod"; +import { AgentRuntimeSchema, EMPTY_RUNTIME } from "../runtimes/schema"; import type { IssueWakeup, IssueWakeupInput, IssueWakeupSummaryRow, PausedWakeup, SystemWakeup, WakeupRun, WorkspaceSystemWakeup } from "../types/issue-wakeup"; import type { WorkspaceWakeupPage, WorkspaceWakeupFilters } from "../types/issue-wakeup"; import { WorkspaceWakeupPageSchema, IssueWakeupSchema, IssueWakeupSummaryRowSchema, PausedWakeupSchema, SystemWakeupSchema, WakeupRunSchema, WorkspaceSystemWakeupSchema } from "./schemas"; @@ -2055,9 +2056,10 @@ export class ApiClient { // workspace_id alone is not enough: the server resolves the workspace from // the slug header first, so a caller listing another workspace's runtimes // must override the header too. - return this.fetch(`/api/runtimes?${search}`, { + const raw = await this.fetch(`/api/runtimes?${search}`, { headers: workspaceHeader(workspaceSlug), }); + return parseWithFallback(raw, AgentRuntimeSchema.array(), [], { endpoint: "listRuntimes" }); } async listCloudRuntimeNodes( @@ -2404,6 +2406,7 @@ export class ApiClient { runtimeId: string, patch: { visibility?: "private" | "public"; + shared_with?: string[]; /** * Custom display name. Pass an empty string to clear it (the server * reverts to the default name). Omit to leave it unchanged — a JSON @@ -2414,13 +2417,14 @@ export class ApiClient { apply_to_machine?: boolean; }, ): Promise { - return this.fetch(`/api/runtimes/${runtimeId}`, { + const raw = await this.fetch(`/api/runtimes/${runtimeId}`, { method: "PATCH", body: JSON.stringify(patch), }); + return parseWithFallback(raw, AgentRuntimeSchema, EMPTY_RUNTIME, { endpoint: "updateRuntime" }); } - // Confirmed counterpart to a public→private visibility PATCH. The PATCH + // Confirmed counterpart to a runtime access restriction PATCH. The PATCH // returns a structured 409 when teammates still have bound agents or active // tasks on the runtime; callers present that plan, then submit its exact // agent/task ID sets here. The server rechecks both sets in one transaction. @@ -2428,12 +2432,14 @@ export class ApiClient { runtimeId: string, expectedNonownerAgentIds: string[], expectedTaskIds: string[], + sharedWith?: string[], ): Promise { await this.fetch(`/api/runtimes/${runtimeId}/revoke-workspace-access`, { method: "POST", body: JSON.stringify({ expected_nonowner_agent_ids: expectedNonownerAgentIds, expected_task_ids: expectedTaskIds, + shared_with: sharedWith, }), }); } diff --git a/packages/core/runtimes/access.test.ts b/packages/core/runtimes/access.test.ts index 8c176b4ab..973d63d86 100644 --- a/packages/core/runtimes/access.test.ts +++ b/packages/core/runtimes/access.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + import { describe, expect, it } from "vitest"; import type { AgentRuntime } from "../types"; import { isRuntimeUsableForUser } from "./access"; @@ -40,6 +42,14 @@ describe("isRuntimeUsableForUser", () => { expect(isRuntimeUsableForUser(makeRuntime(), OTHER)).toBe(false); }); + it("allows only selected members on a shared private runtime", () => { + const runtime = makeRuntime({ shared_with: [OTHER] }); + expect(isRuntimeUsableForUser(runtime, OTHER)).toBe(true); + expect(isRuntimeUsableForUser(runtime, OWNER)).toBe(true); + expect(isRuntimeUsableForUser(runtime, "not-selected")).toBe(false); + expect(isRuntimeUsableForUser({ ...runtime, owner_id: null }, OTHER)).toBe(false); + }); + it("lets anyone use a public runtime", () => { expect( isRuntimeUsableForUser(makeRuntime({ visibility: "public" }), OTHER), diff --git a/packages/core/runtimes/access.ts b/packages/core/runtimes/access.ts index 63852c8cd..3ef1548c7 100644 --- a/packages/core/runtimes/access.ts +++ b/packages/core/runtimes/access.ts @@ -3,10 +3,10 @@ import type { RuntimeDevice } from "../types"; /** * Whether this member may run an agent on `runtime`. * - * A private runtime is usable only by its owner; a public one by anyone in the + * A private runtime is usable by its owner and explicitly selected members; a public one by anyone in the * workspace; an ownerless one by nobody. Workspace role does not enter into * it: a private runtime is someone's own machine, so not even an admin may - * bind an agent to it, and only the owner may flip it to public. The server + * bind an agent without a grant, and only the owner may change sharing. The server * enforces exactly this predicate (`canUseRuntimeForAgent`), so the picker and * the API/CLI never disagree (MUL-6126). * @@ -28,5 +28,6 @@ export function isRuntimeUsableForUser( if (!runtime.owner_id) return false; if (!currentUserId) return true; if (runtime.owner_id === currentUserId) return true; - return runtime.visibility === "public"; + return runtime.visibility === "public" || + (runtime.visibility === "private" && (runtime.shared_with ?? []).includes(currentUserId)); } diff --git a/packages/core/runtimes/mutations.ts b/packages/core/runtimes/mutations.ts index fccd3e12c..bb43c0ff7 100644 --- a/packages/core/runtimes/mutations.ts +++ b/packages/core/runtimes/mutations.ts @@ -42,7 +42,7 @@ export function useUnbindAgentsAndDeleteRuntime(wsId: string) { }); } -// Confirmed public→private transition. Like runtime deletion, this changes +// Confirmed access restriction to private or selected members. Like runtime deletion, this changes // runtime rows, agent bindings, and task presence, so every affected server // projection is invalidated after the transaction settles. export function useRevokeRuntimeWorkspaceAccess(wsId: string) { @@ -52,15 +52,18 @@ export function useRevokeRuntimeWorkspaceAccess(wsId: string) { runtimeId, expectedNonownerAgentIds, expectedTaskIds, + sharedWith, }: { runtimeId: string; expectedNonownerAgentIds: string[]; expectedTaskIds: string[]; + sharedWith?: string[]; }) => api.revokeRuntimeWorkspaceAccess( runtimeId, expectedNonownerAgentIds, expectedTaskIds, + sharedWith, ), onSettled: () => { qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) }); @@ -83,6 +86,7 @@ export function useUpdateRuntime(wsId: string) { runtimeId: string; patch: { visibility?: "private" | "public"; + shared_with?: string[]; // Empty string clears the custom name; omit to leave unchanged. custom_name?: string; apply_to_machine?: boolean; diff --git a/packages/core/runtimes/schema.test.ts b/packages/core/runtimes/schema.test.ts new file mode 100644 index 000000000..d9d9b3914 --- /dev/null +++ b/packages/core/runtimes/schema.test.ts @@ -0,0 +1,18 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { parseWithFallback } from "../api/schema"; +import { AgentRuntimeSchema, EMPTY_RUNTIME } from "./schema"; + +describe("runtime sharing response contract", () => { + it("preserves grants from a new server and defaults missing grants to empty", () => { + const member = "11111111-1111-4111-8111-111111111111"; + expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w", shared_with: [member] }).shared_with).toEqual([member]); + expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w" }).shared_with).toEqual([]); + }); + it.each([null, "all", [123], ["not-a-uuid"]])("fails closed for malformed grants: %j", (shared_with) => { + expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w", shared_with }).shared_with).toEqual([]); + }); + it("falls back safely for a malformed runtime", () => { + expect(parseWithFallback({ id: 123 }, AgentRuntimeSchema, EMPTY_RUNTIME, { endpoint: "test" })).toEqual(EMPTY_RUNTIME); + }); +}); diff --git a/packages/core/runtimes/schema.ts b/packages/core/runtimes/schema.ts new file mode 100644 index 000000000..53604e8b4 --- /dev/null +++ b/packages/core/runtimes/schema.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import type { AgentRuntime } from "../types"; + +export const AgentRuntimeSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + daemon_id: z.string().nullable().catch(null), + name: z.string().catch(""), + custom_name: z.string().nullable().catch(null), + runtime_mode: z.enum(["local", "cloud"]).catch("local"), + provider: z.string().catch(""), + launch_header: z.string().catch(""), + status: z.enum(["online", "offline"]).catch("offline"), + device_info: z.string().catch(""), + metadata: z.record(z.string(), z.unknown()).catch({}), + owner_id: z.string().nullable().catch(null), + visibility: z.enum(["private", "public"]).catch("private"), + shared_with: z.array(z.string().uuid()).catch([]), + profile_id: z.string().nullable().catch(null), + last_seen_at: z.string().nullable().catch(null), + created_at: z.string().catch(""), + updated_at: z.string().catch(""), +}).loose(); + +export const EMPTY_RUNTIME: AgentRuntime = { + id: "", + workspace_id: "", + daemon_id: null, + name: "", + runtime_mode: "local", + provider: "", + launch_header: "", + status: "offline", + device_info: "", + metadata: {}, + owner_id: null, + visibility: "private", + shared_with: [], + last_seen_at: null, + created_at: "", + updated_at: "", +}; diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index 621078cc0..a4a38650a 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -48,8 +48,8 @@ export interface AgentInvocationTargetInput { // Runtime visibility is a separate axis from agent visibility — different // vocabulary because it gates a different action. "private" (default) means -// only the runtime owner can bind agents to it — workspace admins included, -// and only the owner may flip the flag (MUL-6126); "public" opens binding to +// the runtime owner and shared_with members can bind agents to it; +// only the owner may edit access (MUL-6126); "public" opens binding to // any workspace member. Older backends that haven't shipped MUL-2062 omit the // field; the consumer must default to "private" so the strictest behavior is // the fallback. @@ -76,6 +76,8 @@ export interface RuntimeDevice { owner_id: string | null; /** Defaults to "private" when the backend predates the visibility flag. */ visibility: RuntimeVisibility; + /** Workspace user IDs granted access while visibility is private. */ + shared_with?: string[]; /** * The custom runtime profile this registered runtime was launched from, * or `null` for a built-in protocol family. The UI uses this to stamp a diff --git a/packages/views/locales/en/runtimes.json b/packages/views/locales/en/runtimes.json index 4cf602e72..ffb6f4028 100644 --- a/packages/views/locales/en/runtimes.json +++ b/packages/views/locales/en/runtimes.json @@ -174,28 +174,43 @@ "diagnostics_visibility": "Visibility", "visibility_label": { "private": "Private", - "public": "Public" + "public": "Public", + "members": "Specific members" + }, + "visibility_choice": { + "private": "Private", + "public": "Public", + "members": "Members" }, "visibility_hint": { "private": "Only you can create agents on this runtime.", - "public": "Any workspace member can create agents on this runtime." + "public": "Any workspace member can create agents on this runtime.", + "members": "Only you and the selected members can create agents on this runtime." }, "visibility_hint_readonly": { "private": "Only the runtime owner can create agents on this runtime, and only they can switch it to Public.", - "public": "Any workspace member can create agents on this runtime. Only the runtime owner can switch it back to Private." + "public": "Any workspace member can create agents on this runtime. Only the runtime owner can switch it back to Private.", + "members": "Only the owner and selected members can create agents on this runtime." }, + "visibility_members_title": "Share with members", + "visibility_members_search": "Search members", + "visibility_members_none": "No other workspace members yet.", + "visibility_members_no_match": "No members match.", + "visibility_members_edit": "Edit", + "visibility_members_count_one": "{{count}} member", + "visibility_members_count_other": "{{count}} members", "visibility_toast_updated": "Visibility set to {{visibility}}", "visibility_toast_failed": "Failed to update visibility", "visibility_revoke_dialog": { - "title": "Make this Runtime private?", - "description": "Making \"{{name}}\" private will unbind {{count}} teammate agents, cancel {{taskCount}} active runs on this machine, and pause active autopilots assigned to those agents. Your own agents and runs are unaffected.", + "title": "Restrict runtime access?", + "description": "Restricting access to \"{{name}}\" will unbind {{count}} affected agents, cancel {{taskCount}} active runs, and pause active autopilots assigned to those agents. Agents and runs that keep access are unaffected.", "warning": "Access is revoked immediately on the server. Connected daemons stop through their normal cancellation check. An offline machine cannot receive the stop request until it reconnects; its local process may continue meanwhile.", "plan_changed": "The access plan changed while this dialog was open. Review it and confirm again.", "checkbox": "I understand that the listed teammate agents will be unbound, their active runs cancelled, and their active autopilots paused.", "cancel": "Cancel", - "confirm": "Make Runtime private", + "confirm": "Restrict access", "submitting": "Revoking access...", - "toast_success": "Runtime is now private and teammate access was revoked", + "toast_success": "Runtime access updated", "toast_failed": "Failed to revoke runtime access" }, "delete_dialog": { diff --git a/packages/views/locales/fr/runtimes.json b/packages/views/locales/fr/runtimes.json index 47c934c57..7cd6bf1cb 100644 --- a/packages/views/locales/fr/runtimes.json +++ b/packages/views/locales/fr/runtimes.json @@ -174,28 +174,43 @@ "diagnostics_visibility": "Visibilité", "visibility_label": { "private": "Privé", - "public": "Public" + "public": "Public", + "members": "Membres spécifiques" + }, + "visibility_choice": { + "private": "Privé", + "public": "Public", + "members": "Membres" }, "visibility_hint": { "private": "Vous seul pouvez créer des agents sur ce runtime.", - "public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime." + "public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime.", + "members": "Seuls vous et les membres sélectionnés pouvez créer des agents sur cet environnement." }, "visibility_hint_readonly": { "private": "Seul le propriétaire du runtime peut y créer des agents, et lui seul peut le passer en Public.", - "public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime. Seul son propriétaire peut le repasser en Privé." + "public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime. Seul son propriétaire peut le repasser en Privé.", + "members": "Seuls le propriétaire et les membres sélectionnés peuvent créer des agents sur cet environnement." }, + "visibility_members_title": "Partager avec des membres", + "visibility_members_search": "Rechercher des membres", + "visibility_members_none": "Aucun autre membre dans l’espace de travail.", + "visibility_members_no_match": "Aucun membre ne correspond.", + "visibility_members_edit": "Modifier", + "visibility_members_count_one": "{{count}} membre", + "visibility_members_count_other": "{{count}} membres", "visibility_toast_updated": "Visibilité définie sur {{visibility}}", "visibility_toast_failed": "Échec de la mise à jour de la visibilité", "visibility_revoke_dialog": { - "title": "Rendre ce runtime privé ?", - "description": "Rendre « {{name}} » privé dissociera {{count}} agents d'autres membres, annulera {{taskCount}} exécutions actives sur cette machine et mettra en pause les automatisations actives assignées à ces agents. Vos propres agents et exécutions ne seront pas affectés.", + "title": "Restreindre l’accès à cet environnement ?", + "description": "Restreindre l’accès à « {{name}} » détachera {{count}} agents concernés, annulera {{taskCount}} exécutions actives et suspendra leurs autopilotes actifs. Les agents et exécutions qui conservent l’accès ne sont pas affectés.", "warning": "L'accès est révoqué immédiatement sur le serveur. Les daemons connectés s'arrêtent lors de leur vérification habituelle des annulations. Une machine hors ligne ne recevra la demande d'arrêt qu'à sa reconnexion ; son processus local peut continuer entre-temps.", "plan_changed": "Le plan d'accès a changé pendant que cette boîte de dialogue était ouverte. Vérifiez-le et confirmez à nouveau.", "checkbox": "Je comprends que les agents des autres membres listés seront dissociés, leurs exécutions actives annulées et leurs automatisations actives mises en pause.", "cancel": "Annuler", - "confirm": "Rendre le runtime privé", + "confirm": "Restreindre l’accès", "submitting": "Révocation de l'accès...", - "toast_success": "Le runtime est désormais privé et l'accès des autres membres a été révoqué", + "toast_success": "Accès à l’environnement mis à jour", "toast_failed": "Échec de la révocation de l'accès au runtime" }, "delete_dialog": { diff --git a/packages/views/locales/ja/runtimes.json b/packages/views/locales/ja/runtimes.json index 4fbddb0a1..8bfc204f7 100644 --- a/packages/views/locales/ja/runtimes.json +++ b/packages/views/locales/ja/runtimes.json @@ -169,28 +169,42 @@ "diagnostics_visibility": "公開範囲", "visibility_label": { "private": "非公開", - "public": "公開" + "public": "公開", + "members": "指定メンバー" + }, + "visibility_choice": { + "private": "非公開", + "public": "公開", + "members": "メンバー" }, "visibility_hint": { "private": "あなたのみが、このランタイムでエージェントを作成できます。", - "public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。" + "public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。", + "members": "あなたと選択したメンバーだけがこのランタイムでエージェントを作成できます。" }, "visibility_hint_readonly": { "private": "このランタイムでエージェントを作成できるのは所有者だけで、公開に切り替えられるのも所有者だけです。", - "public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。非公開に戻せるのは所有者だけです。" + "public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。非公開に戻せるのは所有者だけです。", + "members": "所有者と選択したメンバーのみ、このランタイムでエージェントを作成できます。" }, + "visibility_members_title": "メンバーと共有", + "visibility_members_search": "メンバーを検索", + "visibility_members_none": "ワークスペースに他のメンバーはいません。", + "visibility_members_no_match": "一致するメンバーはいません。", + "visibility_members_edit": "編集", + "visibility_members_count_other": "{{count}} 人のメンバー", "visibility_toast_updated": "公開範囲を {{visibility}} に設定しました", "visibility_toast_failed": "公開範囲を更新できませんでした", "visibility_revoke_dialog": { - "title": "この Runtime を private にしますか?", - "description": "\"{{name}}\" を private にすると、他のメンバーのエージェント {{count}} 件の接続を解除し、このマシン上のアクティブな実行 {{taskCount}} 件をキャンセルします。これらのエージェントに割り当てられた有効な自動化も一時停止します。自分のエージェントと実行には影響しません。", + "title": "ランタイムへのアクセスを制限しますか?", + "description": "「{{name}}」へのアクセスを制限すると、対象のエージェント {{count}} 件の紐付けを解除し、実行中の {{taskCount}} 件をキャンセルして、対象エージェントの有効なオートパイロットを一時停止します。アクセスを維持するエージェントと実行には影響しません。", "warning": "サーバー上のアクセスは直ちに取り消されます。接続中のデーモンは通常のキャンセル確認で停止します。オフラインのマシンは再接続するまで停止要求を受信できず、その間ローカルプロセスが続く場合があります。", "plan_changed": "このダイアログを開いている間にアクセス計画が変わりました。内容を確認して、もう一度確定してください。", "checkbox": "一覧の他メンバーのエージェントの接続が解除され、アクティブな実行がキャンセルされ、有効な自動化が一時停止することを理解しました。", "cancel": "キャンセル", - "confirm": "Runtime を private にする", + "confirm": "アクセスを制限", "submitting": "アクセスを取り消しています...", - "toast_success": "Runtime を private にし、他メンバーのアクセスを取り消しました", + "toast_success": "ランタイムのアクセスを更新しました", "toast_failed": "Runtime アクセスを取り消せませんでした" }, "delete_dialog": { diff --git a/packages/views/locales/ko/runtimes.json b/packages/views/locales/ko/runtimes.json index bb59a2ff0..faafdb2a2 100644 --- a/packages/views/locales/ko/runtimes.json +++ b/packages/views/locales/ko/runtimes.json @@ -169,28 +169,42 @@ "diagnostics_visibility": "공개 범위", "visibility_label": { "private": "비공개", - "public": "공개" + "public": "공개", + "members": "지정 멤버" + }, + "visibility_choice": { + "private": "비공개", + "public": "공개", + "members": "멤버" }, "visibility_hint": { "private": "본인만 이 런타임에 에이전트를 만들 수 있습니다.", - "public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다." + "public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다.", + "members": "나와 선택한 멤버만 이 런타임에서 에이전트를 만들 수 있습니다." }, "visibility_hint_readonly": { "private": "이 런타임에 에이전트를 만들 수 있는 사람은 런타임 owner뿐이며, 공개로 바꿀 수 있는 사람도 owner뿐입니다.", - "public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다. 다시 비공개로 바꿀 수 있는 사람은 런타임 owner뿐입니다." + "public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다. 다시 비공개로 바꿀 수 있는 사람은 런타임 owner뿐입니다.", + "members": "소유자와 선택한 멤버만 이 런타임에서 에이전트를 만들 수 있습니다." }, + "visibility_members_title": "멤버와 공유", + "visibility_members_search": "멤버 검색", + "visibility_members_none": "워크스페이스에 다른 멤버가 없습니다.", + "visibility_members_no_match": "일치하는 멤버가 없습니다.", + "visibility_members_edit": "편집", + "visibility_members_count_other": "멤버 {{count}}명", "visibility_toast_updated": "공개 범위를 {{visibility}}(으)로 설정했습니다", "visibility_toast_failed": "공개 범위를 업데이트하지 못했습니다", "visibility_revoke_dialog": { - "title": "이 Runtime을 private으로 전환할까요?", - "description": "\"{{name}}\"을 private으로 전환하면 다른 멤버의 에이전트 {{count}}개 연결이 해제되고 이 컴퓨터의 활성 실행 {{taskCount}}개가 취소됩니다. 해당 에이전트에 할당된 활성 자동화도 일시 중지됩니다. 본인의 에이전트와 실행에는 영향을 주지 않습니다.", + "title": "런타임 접근을 제한할까요?", + "description": "\"{{name}}\" 접근을 제한하면 영향받는 에이전트 {{count}}개의 연결을 해제하고 실행 {{taskCount}}개를 취소하며 해당 에이전트의 활성 오토파일럿을 일시 중지합니다. 접근 권한이 유지되는 에이전트와 실행은 영향받지 않습니다.", "warning": "서버의 접근 권한은 즉시 취소됩니다. 연결된 데몬은 일반 취소 확인 과정에서 중단합니다. 오프라인 컴퓨터는 다시 연결될 때까지 중단 요청을 받을 수 없으므로 그동안 로컬 프로세스가 계속 실행될 수 있습니다.", "plan_changed": "이 대화 상자가 열린 동안 접근 계획이 변경되었습니다. 내용을 검토하고 다시 확인하세요.", "checkbox": "목록의 다른 멤버 에이전트 연결이 해제되고 활성 실행이 취소되며 활성 자동화가 일시 중지됨을 이해했습니다.", "cancel": "취소", - "confirm": "Runtime을 private으로 전환", + "confirm": "접근 제한", "submitting": "접근을 취소하는 중...", - "toast_success": "Runtime이 private으로 전환되었고 다른 멤버의 접근이 취소되었습니다", + "toast_success": "런타임 접근 권한을 변경했습니다", "toast_failed": "Runtime 접근을 취소하지 못했습니다" }, "delete_dialog": { diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json index ccb995f6c..64c9f71d4 100644 --- a/packages/views/locales/zh-Hans/runtimes.json +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -169,28 +169,42 @@ "diagnostics_visibility": "可见性", "visibility_label": { "private": "私有", - "public": "公开" + "public": "公开", + "members": "指定成员" + }, + "visibility_choice": { + "private": "私有", + "public": "公开", + "members": "指定成员" }, "visibility_hint": { "private": "仅你可以在此运行时上创建智能体。", - "public": "工作区内任何成员都可以在此运行时上创建智能体。" + "public": "工作区内任何成员都可以在此运行时上创建智能体。", + "members": "仅你和所选成员可以在此运行时上创建智能体。" }, "visibility_hint_readonly": { "private": "仅运行时所有者可以在此运行时上创建智能体,也只有他们能切换为「公开」。", - "public": "工作区内任何成员都可以在此运行时上创建智能体。只有运行时所有者能切回「私有」。" + "public": "工作区内任何成员都可以在此运行时上创建智能体。只有运行时所有者能切回「私有」。", + "members": "仅所有者和所选成员可以在此运行时创建智能体。" }, + "visibility_members_title": "共享给成员", + "visibility_members_search": "搜索成员", + "visibility_members_none": "工作区暂无其他成员。", + "visibility_members_no_match": "没有匹配的成员。", + "visibility_members_edit": "编辑", + "visibility_members_count_other": "{{count}} 位成员", "visibility_toast_updated": "可见性已设为\"{{visibility}}\"", "visibility_toast_failed": "更新可见性失败", "visibility_revoke_dialog": { - "title": "将此运行时设为私有?", - "description": "将 \"{{name}}\" 设为私有后,会解绑 {{count}} 个其他成员的智能体、取消此机器上的 {{taskCount}} 次活动运行,并暂停分配给这些智能体的已启用自动化。你自己的智能体和运行不受影响。", + "title": "限制运行时访问权限?", + "description": "限制 \"{{name}}\" 的访问权限将解绑 {{count}} 个受影响的智能体、取消 {{taskCount}} 次运行,并暂停分配给这些智能体的自动化。仍有权限的智能体和运行不受影响。", "warning": "服务端会立即撤销访问。已连接的守护进程会在常规取消检查时停止运行。离线机器需重新连接才能收到停止请求,本地进程在此期间可能继续执行。", "plan_changed": "弹窗打开期间,访问方案发生了变化。请重新确认。", "checkbox": "我已了解:列表中的其他成员智能体会被解绑,其活动运行会被取消,已启用的自动化会被暂停。", "cancel": "取消", - "confirm": "设为私有", + "confirm": "限制访问", "submitting": "正在撤销访问...", - "toast_success": "运行时已设为私有,其他成员的访问已撤销", + "toast_success": "已更新运行时访问权限", "toast_failed": "撤销运行时访问失败" }, "delete_dialog": { diff --git a/packages/views/runtimes/components/revoke-runtime-access-dialog.test.tsx b/packages/views/runtimes/components/revoke-runtime-access-dialog.test.tsx index b0bf5adac..314097aa5 100644 --- a/packages/views/runtimes/components/revoke-runtime-access-dialog.test.tsx +++ b/packages/views/runtimes/components/revoke-runtime-access-dialog.test.tsx @@ -46,21 +46,21 @@ describe("runtime access revocation confirmation", () => { }; render( - + ); - expect(screen.getByRole("alertdialog", { name: "Make this Runtime private?" })).toHaveAccessibleDescription(/pause active autopilots/); - const confirm = screen.getByRole("button", { name: "Make Runtime private" }); + expect(screen.getByRole("alertdialog", { name: "Restrict runtime access?" })).toHaveAccessibleDescription(/pause active autopilots/); + const confirm = screen.getByRole("button", { name: "Restrict access" }); expect(confirm).toBeDisabled(); fireEvent.click(screen.getByRole("checkbox")); fireEvent.click(confirm); expect(await screen.findByRole("status")).toHaveTextContent("Review it and confirm again"); expect(confirm).toBeDisabled(); expect(close).not.toHaveBeenCalled(); - expect(revoke).toHaveBeenNthCalledWith(1, runtime.id, [agentId], [taskId]); + expect(revoke).toHaveBeenNthCalledWith(1, runtime.id, [agentId], [taskId], ["remaining-member"]); fireEvent.click(screen.getByRole("checkbox")); fireEvent.click(confirm); await waitFor(() => expect(close).toHaveBeenCalledWith(false)); - expect(revoke).toHaveBeenNthCalledWith(2, runtime.id, [agentId], [taskId, nextTaskId]); + expect(revoke).toHaveBeenNthCalledWith(2, runtime.id, [agentId], [taskId, nextTaskId], ["remaining-member"]); }); }); diff --git a/packages/views/runtimes/components/revoke-runtime-access-dialog.tsx b/packages/views/runtimes/components/revoke-runtime-access-dialog.tsx index 74610e000..3388a9557 100644 --- a/packages/views/runtimes/components/revoke-runtime-access-dialog.tsx +++ b/packages/views/runtimes/components/revoke-runtime-access-dialog.tsx @@ -44,12 +44,14 @@ export function RevokeRuntimeAccessDialog({ runtime, wsId, initialPlan, + sharedWith, }: { open: boolean; onOpenChange: (open: boolean) => void; runtime: AgentRuntime; wsId: string; initialPlan: RuntimeAccessRevocationPlan | null; + sharedWith?: string[]; }) { const { t } = useT("runtimes"); const revokeAccess = useRevokeRuntimeWorkspaceAccess(wsId); @@ -72,6 +74,7 @@ export function RevokeRuntimeAccessDialog({ runtimeId: runtime.id, expectedNonownerAgentIds: plan.agents.map((agent) => agent.id), expectedTaskIds: plan.activeTaskIds, + sharedWith, }, { onSuccess: () => { diff --git a/packages/views/runtimes/components/runtime-access-editor.tsx b/packages/views/runtimes/components/runtime-access-editor.tsx new file mode 100644 index 000000000..2f083e5ea --- /dev/null +++ b/packages/views/runtimes/components/runtime-access-editor.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useMemo, useState, type ComponentProps } from "react"; +import { Globe, Lock, Users } from "lucide-react"; +import { toast } from "sonner"; +import { useQuery } from "@tanstack/react-query"; +import type { AgentRuntime } from "@multica/core/types"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { memberListOptions } from "@multica/core/workspace/queries"; +import { useUpdateRuntime } from "@multica/core/runtimes/mutations"; +import { Button } from "@multica/ui/components/ui/button"; +import { Checkbox } from "@multica/ui/components/ui/checkbox"; +import { Input } from "@multica/ui/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@multica/ui/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { useT } from "../../i18n"; +import { + RevokeRuntimeAccessDialog, + parseRuntimeAccessRevocationPlan, + type RuntimeAccessRevocationPlan, +} from "./revoke-runtime-access-dialog"; + +type RuntimeAccessScope = "private" | "public" | "members"; + +// Below this size the member list fits without scrolling, so a search field +// would only add noise. +const MEMBER_SEARCH_THRESHOLD = 7; + +const SCOPE_ICON = { private: Lock, public: Globe, members: Users } as const; + +export function runtimeAccessScope(runtime: AgentRuntime): RuntimeAccessScope { + if (runtime.visibility === "public") return "public"; + return (runtime.shared_with?.length ?? 0) > 0 ? "members" : "private"; +} + +export function RuntimeAccessReadout({ runtime }: { runtime: AgentRuntime }) { + const { t } = useT("runtimes"); + const scope = runtimeAccessScope(runtime); + const Icon = SCOPE_ICON[scope]; + return ( + + + + {t(($) => $.detail.visibility_label[scope])} + + } /> + {t(($) => $.detail.visibility_hint_readonly[scope])} + + ); +} + +/** + * Owner-only access control. Private and Public apply in one click, as before + * member sharing existed; restricting access still goes through the reviewed + * revocation dialog when the server reports affected agents or runs. Choosing + * members opens a draft popover that persists only on Save, so a partial + * selection is never shared. + */ +export function RuntimeAccessEditor({ runtime }: { runtime: AgentRuntime }) { + const { t } = useT("runtimes"); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const updateRuntime = useUpdateRuntime(wsId); + const current = runtimeAccessScope(runtime); + const persisted = useMemo(() => runtime.shared_with ?? [], [runtime.shared_with]); + const [pickerOpen, setPickerOpen] = useState(false); + const [revokePlan, setRevokePlan] = useState(null); + const [revokeOpen, setRevokeOpen] = useState(false); + const [revokeMembers, setRevokeMembers] = useState([]); + const disabled = updateRuntime.isPending || revokeOpen; + + const recipients = useMemo( + () => members.filter((member) => persisted.includes(member.user_id)), + [members, persisted], + ); + + const apply = (scope: RuntimeAccessScope, sharedWith: string[], onApplied?: () => void) => { + updateRuntime.mutate( + { + runtimeId: runtime.id, + patch: { visibility: scope === "public" ? "public" : "private", shared_with: sharedWith }, + }, + { + onSuccess: () => { + onApplied?.(); + toast.success(t(($) => $.detail.visibility_toast_updated, { + visibility: t(($) => $.detail.visibility_label[scope]), + })); + }, + onError: (err) => { + const plan = parseRuntimeAccessRevocationPlan(err); + if (plan) { + onApplied?.(); + setRevokeMembers(sharedWith); + setRevokePlan(plan); + setRevokeOpen(true); + return; + } + toast.error(err instanceof Error && err.message ? err.message : t(($) => $.detail.visibility_toast_failed)); + }, + }, + ); + }; + + return ( +
+
$.detail.diagnostics_visibility)} + className="flex w-full items-center gap-0.5 rounded-md bg-muted p-0.5" + > + {(["private", "public"] as const).map((scope) => ( + { + if (current !== scope) apply(scope, []); + }} + /> + ))} + setPickerOpen(open && !disabled)}> + } + /> + + {pickerOpen ? ( + member.user_id !== runtime.owner_id)} + initial={persisted} + saving={updateRuntime.isPending} + onCancel={() => setPickerOpen(false)} + onSave={(ids) => apply("members", ids, () => setPickerOpen(false))} + /> + ) : null} + + +
+ {current === "members" ? ( + + ) : null} + +
+ ); +} + +function ScopeButton({ + scope, + active, + disabled, + ...props +}: { + scope: RuntimeAccessScope; + active: boolean; + disabled: boolean; +} & ComponentProps<"button">) { + const { t } = useT("runtimes"); + const Icon = SCOPE_ICON[scope]; + return ( + + + + ); +} + +function MemberPicker({ + members, + initial, + saving, + onCancel, + onSave, +}: { + members: Array<{ user_id: string; name: string }>; + initial: string[]; + saving: boolean; + onCancel: () => void; + onSave: (ids: string[]) => void; +}) { + const { t } = useT("runtimes"); + const { t: tc } = useT("common"); + const [selected, setSelected] = useState(initial); + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const visible = needle + ? members.filter((member) => member.name.toLowerCase().includes(needle)) + : members; + // Order-insensitive: the server stores the list sorted. + const dirty = selected.length !== initial.length || selected.some((id) => !initial.includes(id)); + + return ( +
+
+

{t(($) => $.detail.visibility_members_title)}

+ {members.length >= MEMBER_SEARCH_THRESHOLD ? ( + setQuery(event.target.value)} + placeholder={t(($) => $.detail.visibility_members_search)} + aria-label={t(($) => $.detail.visibility_members_search)} + className="h-8" + autoFocus + /> + ) : null} +
+
+ {members.length === 0 || visible.length === 0 ? ( +

+ {members.length === 0 + ? t(($) => $.detail.visibility_members_none) + : t(($) => $.detail.visibility_members_no_match)} +

+ ) : visible.map((member) => { + const id = `runtime-share-${member.user_id}`; + return ( +
+ setSelected((ids) => checked === true + ? [...ids, member.user_id] + : ids.filter((value) => value !== member.user_id))} + /> + +
+ ); + })} +
+
+ + +
+
+ ); +} diff --git a/packages/views/runtimes/components/runtime-detail-visibility.test.tsx b/packages/views/runtimes/components/runtime-detail-visibility.test.tsx index 3c28c3b65..42e8d0da4 100644 --- a/packages/views/runtimes/components/runtime-detail-visibility.test.tsx +++ b/packages/views/runtimes/components/runtime-detail-visibility.test.tsx @@ -226,9 +226,10 @@ describe("RuntimeDetail visibility section", () => { it("shows owner-editable visibility choices when the caller owns the runtime", () => { renderDetail(makeRuntime({ owner_id: "user-me" })); - expect(screen.getByText("Visibility")).toBeInTheDocument(); - expect(screen.getByText("Private")).toBeInTheDocument(); - expect(screen.getByText("Public")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Visibility" })).toBeInTheDocument(); + for (const name of ["Private", "Public", "Members"]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + } }); it("opens a reviewed revocation plan instead of silently making an in-use public runtime private", async () => { @@ -239,11 +240,11 @@ describe("RuntimeDetail visibility section", () => { }); renderDetail(makeRuntime({ visibility: "public" })); - fireEvent.click(screen.getByText("Private")); + fireEvent.click(screen.getByRole("button", { name: "Private" })); - expect(await screen.findByText("Make this Runtime private?")).toBeInTheDocument(); + expect(await screen.findByText("Restrict runtime access?")).toBeInTheDocument(); expect(screen.getByText("Teammate Agent")).toBeInTheDocument(); - expect(screen.getByText(/Your own agents and runs are unaffected/)).toBeInTheDocument(); + expect(screen.getByText(/Agents and runs that keep access are unaffected/)).toBeInTheDocument(); expect(screen.getByText(/pause active autopilots assigned to those agents/)).toBeInTheDocument(); }); @@ -292,14 +293,90 @@ describe("RuntimeDetail visibility section", () => { expect(screen.getAllByText("Research Pi")).toHaveLength(2); }); - it("flips visibility to public when the owner clicks the Public choice", async () => { + it("applies Public in one click, without a separate Save step", async () => { renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" })); - fireEvent.click(screen.getByText("Public")); + expect(screen.getByRole("button", { name: "Private" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Public" })); await waitFor(() => - expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { visibility: "public" }), + expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { visibility: "public", shared_with: [] }), ); }); + it("does not send a request when the active scope is clicked again", () => { + renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" })); + fireEvent.click(screen.getByRole("button", { name: "Private" })); + expect(mockUpdateRuntime).not.toHaveBeenCalled(); + }); + + it("saves the selected members only after explicit confirmation", async () => { + mockQueryData.members = [ + { user_id: "user-me", role: "member", name: "Me" }, + { user_id: "user-alice", role: "member", name: "Alice" }, + { user_id: "user-bob", role: "member", name: "Bob" }, + ]; + renderDetail(makeRuntime({})); + fireEvent.click(screen.getByRole("button", { name: "Members" })); + expect(await screen.findByText("Share with members")).toBeInTheDocument(); + // The owner is never offered as a recipient. + expect(screen.queryByRole("checkbox", { name: "Me" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + fireEvent.click(screen.getByRole("checkbox", { name: "Alice" })); + expect(mockUpdateRuntime).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { + visibility: "private", shared_with: ["user-alice"], + })); + }); + + it("discards the member draft on Cancel", async () => { + mockQueryData.members = [ + { user_id: "user-me", role: "member", name: "Me" }, + { user_id: "user-alice", role: "member", name: "Alice" }, + ]; + renderDetail(makeRuntime({})); + fireEvent.click(screen.getByRole("button", { name: "Members" })); + fireEvent.click(await screen.findByRole("checkbox", { name: "Alice" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByText("Share with members")).not.toBeInTheDocument()); + expect(mockUpdateRuntime).not.toHaveBeenCalled(); + }); + + it("summarizes current recipients and reopens the picker with them selected", async () => { + mockQueryData.members = [ + { user_id: "user-me", role: "member", name: "Me" }, + { user_id: "user-alice", role: "member", name: "Alice" }, + { user_id: "user-bob", role: "member", name: "Bob" }, + ]; + renderDetail(makeRuntime({ shared_with: ["user-alice"] })); + expect(screen.getByRole("button", { name: "Members" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: /Alice.*Edit/ })); + expect(await screen.findByRole("checkbox", { name: "Alice" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Bob" })).not.toBeChecked(); + // An unchanged selection is not saveable. + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("filters a long member list by name", async () => { + mockQueryData.members = [ + { user_id: "user-me", role: "member", name: "Me" }, + ...["Alice", "Bob", "Carol", "Dave", "Erin", "Frank", "Grace"].map((name) => ({ + user_id: `user-${name.toLowerCase()}`, role: "member", name, + })), + ]; + renderDetail(makeRuntime({})); + fireEvent.click(screen.getByRole("button", { name: "Members" })); + fireEvent.change(await screen.findByRole("textbox", { name: "Search members" }), { target: { value: "gr" } }); + expect(screen.getByRole("checkbox", { name: "Grace" })).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Alice" })).not.toBeInTheDocument(); + }); + + it("shows a shared recipient the read-only scope and runtime usage", () => { + renderDetail(makeRuntime({ owner_id: "someone-else", shared_with: ["user-me"] })); + expect(screen.getByText("Specific members")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect(screen.getByTestId("usage-section")).toBeInTheDocument(); + }); + it("renders a read-only visibility chip when the caller cannot edit", () => { renderDetail(makeRuntime({ owner_id: "someone-else", visibility: "public" })); expect(screen.getByText("Public")).toBeInTheDocument(); diff --git a/packages/views/runtimes/components/runtime-detail.tsx b/packages/views/runtimes/components/runtime-detail.tsx index 1da58a3ea..63bf65186 100644 --- a/packages/views/runtimes/components/runtime-detail.tsx +++ b/packages/views/runtimes/components/runtime-detail.tsx @@ -5,7 +5,6 @@ import { Trash2, ChevronRight, Cpu, - Globe, Lock, } from "lucide-react"; import { toast } from "sonner"; @@ -19,7 +18,6 @@ import type { import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; -import { useUpdateRuntime } from "@multica/core/runtimes/mutations"; import { deriveRuntimeHealth, isRuntimeUsableForUser, @@ -45,11 +43,7 @@ import { HealthBadge } from "./shared"; import { ProviderLogo } from "./provider-logo"; import { UsageSection } from "./usage-section"; import { DeleteRuntimeDialog } from "./delete-runtime-dialog"; -import { - RevokeRuntimeAccessDialog, - parseRuntimeAccessRevocationPlan, - type RuntimeAccessRevocationPlan, -} from "./revoke-runtime-access-dialog"; +import { RuntimeAccessEditor, RuntimeAccessReadout } from "./runtime-access-editor"; import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog"; import { runtimeRowLabel } from "./runtime-machines"; import { useT, useTimeAgo } from "../../i18n"; @@ -508,9 +502,9 @@ function DiagnosticsCard({ {t(($) => $.detail.diagnostics_visibility)} {canEditVisibility ? ( - + ) : ( - + )} {canDelete && ( @@ -536,144 +530,3 @@ function DiagnosticsCard({ ); } - -// VisibilityReadout renders a static "Private" / "Public" pill for everyone -// who is not the runtime owner — workspace admins included (MUL-6126). Its -// tooltip is phrased in the third person for that reason; the editor's own -// hints stay in the second person. The description used to sit under the -// chip; it now lives in the hover tooltip so the Diagnostics column stays -// compact and matches the surrounding sections. Older backends that omit the -// field render as "Private" to match the strict default. -function VisibilityReadout({ runtime }: { runtime: AgentRuntime }) { - const { t } = useT("runtimes"); - const visibility = runtime.visibility === "public" ? "public" : "private"; - const Icon = visibility === "public" ? Globe : Lock; - return ( - - - - - {t(($) => $.detail.visibility_label[visibility])} - - - } - /> - - {t(($) => $.detail.visibility_hint_readonly[visibility])} - - - ); -} - -// VisibilityEditor lets the runtime owner flip public↔private. Owner only — -// the PATCH endpoint refuses a workspace admin here (canSetRuntimeVisibility); -// this is a UI gate, not a security boundary. Per-choice description text lives in the hover -// tooltip so the two buttons stay a tight icon+label pair instead of the -// previous two-line block that competed with the surrounding cards. -function VisibilityEditor({ runtime }: { runtime: AgentRuntime }) { - const { t } = useT("runtimes"); - const wsId = useWorkspaceId(); - const updateRuntime = useUpdateRuntime(wsId); - const current = runtime.visibility === "public" ? "public" : "private"; - const [revokePlan, setRevokePlan] = useState(null); - const [revokeOpen, setRevokeOpen] = useState(false); - - const flip = (next: "private" | "public") => { - if (next === current) return; - updateRuntime.mutate( - { runtimeId: runtime.id, patch: { visibility: next } }, - { - onSuccess: () => - toast.success( - t(($) => $.detail.visibility_toast_updated, { - visibility: t(($) => $.detail.visibility_label[next]), - }), - ), - onError: (err) => { - const plan = parseRuntimeAccessRevocationPlan(err); - if (plan) { - setRevokePlan(plan); - setRevokeOpen(true); - return; - } - toast.error( - err instanceof Error && err.message - ? err.message - : t(($) => $.detail.visibility_toast_failed), - ); - }, - }, - ); - }; - - return ( - <> -
- } - label={t(($) => $.detail.visibility_label.private)} - tooltip={t(($) => $.detail.visibility_hint.private)} - disabled={updateRuntime.isPending} - onClick={() => flip("private")} - /> - } - label={t(($) => $.detail.visibility_label.public)} - tooltip={t(($) => $.detail.visibility_hint.public)} - disabled={updateRuntime.isPending} - onClick={() => flip("public")} - /> -
- - - ); -} - -function VisibilityChoice({ - active, - icon, - label, - tooltip, - disabled, - onClick, -}: { - active: boolean; - icon: React.ReactNode; - label: string; - tooltip: string; - disabled: boolean; - onClick: () => void; -}) { - return ( - - - {icon} - {label} - - } - /> - {tooltip} - - ); -} diff --git a/packages/views/runtimes/components/runtime-list.tsx b/packages/views/runtimes/components/runtime-list.tsx index 9c4069010..80ae1b180 100644 --- a/packages/views/runtimes/components/runtime-list.tsx +++ b/packages/views/runtimes/components/runtime-list.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, ExternalLink, Globe, + Users, Loader2, MoreHorizontal, Pencil, @@ -262,19 +263,21 @@ function PendingRuntimeBadge({ runtime }: { runtime: AgentRuntime }) { // `🔒 Private` chip on every row turns the whole column into noise. function VisibilityBadge({ runtime }: { runtime: AgentRuntime }) { const { t } = useT("runtimes"); - if (runtime.visibility !== "public") return null; + const scope = runtime.visibility === "public" ? "public" : "members"; + if (scope === "members" && !runtime.shared_with?.length) return null; + const Icon = scope === "public" ? Globe : Users; return ( - - {t(($) => $.detail.visibility_label.public)} + + {t(($) => $.detail.visibility_label[scope])} } /> - {t(($) => $.detail.visibility_hint.public)} + {t(($) => $.detail.visibility_hint_readonly[scope])} ); diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index 269a6459b..3a604fd4a 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -1108,10 +1108,10 @@ func (h *Handler) loadAgentRuntimeAvailability(ctx context.Context, agents []db. continue } // ListAgentRuntimes exposes every row to workspace owner/admin and only - // owner/public rows to regular members. Keep the coarse bridge for the + // owner/shared/public rows to regular members. Keep the coarse bridge for the // rows that the viewer's runtime list cannot carry. if runtime.Visibility == "public" || - (runtime.OwnerID.Valid && uuidToString(runtime.OwnerID) == viewerID) { + service.RuntimeAllowsUser(runtime, parseUUID(viewerID)) { continue } result[uuidToString(runtime.ID)] = deriveAgentRuntimeAvailability(runtime, now) @@ -1467,7 +1467,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { return } if !canUseRuntimeForAgent(member, runtime) { - writeError(w, http.StatusForbidden, "this runtime is private; only its owner can create agents on it") + writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before creating agents") return } @@ -1610,7 +1610,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { return } if !canUseRuntimeForAgent(member, runtime) { - writeError(w, http.StatusForbidden, "this runtime is private; only its owner can create agents on it") + writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before creating agents") return } @@ -2033,14 +2033,14 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { return } if !canUseRuntimeForAgent(member, runtime) { - writeError(w, http.StatusForbidden, "this runtime is private; only its owner can move agents onto it") + writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before moving agents onto it") return } // Task delivery authorizes private runtimes against the agent owner, // not the member making this update. Admin management permission must // not save a binding that the existing delivery gate will reject. - if runtime.Visibility == "private" && runtime.OwnerID != existing.OwnerID { - writeError(w, http.StatusForbidden, "this runtime is private; its owner must match the agent owner") + if runtime.Visibility == "private" && !service.RuntimeAllowsUser(runtime, existing.OwnerID) { + writeError(w, http.StatusForbidden, "this runtime is private; its owner must grant the agent owner access") return } params.RuntimeID = runtime.ID @@ -2371,7 +2371,11 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { return } if !canUseRuntimeForAgent(member, runtime) { - writeError(w, http.StatusForbidden, "this runtime is private; only its owner can move agents onto it") + writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before moving agents onto it") + return + } + if runtime.Visibility == "private" && !service.RuntimeAllowsUser(runtime, existing.OwnerID) { + writeError(w, http.StatusForbidden, "the agent owner does not have access to this runtime") return } params.RuntimeMode = pgtype.Text{String: runtime.RuntimeMode, Valid: true} diff --git a/server/internal/handler/agent_builder.go b/server/internal/handler/agent_builder.go index db7ff2636..23ec93419 100644 --- a/server/internal/handler/agent_builder.go +++ b/server/internal/handler/agent_builder.go @@ -353,7 +353,7 @@ func (h *Handler) SaveAgentBuilderDraft(w http.ResponseWriter, r *http.Request) // resolveBuilderRuntime loads a runtime the caller is allowed to execute a // builder conversation on. Shared by session create and runtime switch so both // enforce the same three gates in the same order: it exists in this workspace, -// this member may use it (private runtimes stay owner/admin-only), and it is +// this member may use it (private runtimes require owner consent), and it is // online. verb names the attempted action in the offline error so the two call // sites read naturally. func (h *Handler) resolveBuilderRuntime(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID pgtype.UUID, runtimeID, verb string) (db.AgentRuntime, bool) { @@ -381,7 +381,7 @@ func (h *Handler) authorizeBuilderRuntime(w http.ResponseWriter, r *http.Request return false } if !canUseRuntimeForAgent(member, runtime) { - writeError(w, http.StatusForbidden, "this runtime is private; only its owner can use it") + writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access") return false } if runtime.Status != "online" { diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 48938d6f9..10c2319ff 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -852,6 +852,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { OwnerID: prow.OwnerID, LegacyDaemonID: prow.LegacyDaemonID, Visibility: prow.Visibility, + SharedWith: prow.SharedWith, ProfileID: prow.ProfileID, } } else { @@ -905,6 +906,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { OwnerID: row.OwnerID, LegacyDaemonID: row.LegacyDaemonID, Visibility: row.Visibility, + SharedWith: row.SharedWith, ProfileID: row.ProfileID, } } @@ -2464,8 +2466,7 @@ func (h *Handler) finalizeClaimDelivery( Detail: "agent runtime changed before delivery", } } - if locked.Visibility == "private" && locked.OwnerID.Valid && - (!agent.OwnerID.Valid || agent.OwnerID != locked.OwnerID) { + if locked.Visibility == "private" && locked.OwnerID.Valid && !service.RuntimeAllowsUser(locked, agent.OwnerID) { agentOwnerID = agent.OwnerID return &service.ClaimDeliveryAuthzError{ Reason: "error_runtime_access_denied", @@ -2883,8 +2884,7 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu "error_agent_runtime_changed", http.StatusConflict, "agent runtime changed before task delivery", ) } - if runtime.Visibility == "private" && runtime.OwnerID.Valid && - (!agent.OwnerID.Valid || agent.OwnerID != runtime.OwnerID) { + if runtime.Visibility == "private" && runtime.OwnerID.Valid && !service.RuntimeAllowsUser(runtime, agent.OwnerID) { userMessage := "This private runtime cannot run the assigned agent because the agent and runtime have different owners." if !agent.OwnerID.Valid { userMessage = "This private runtime cannot run the assigned agent because the agent has no owner." diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index 7bcb9a3b8..b0124b1aa 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -21,7 +21,6 @@ import ( "github.com/multica-ai/multica/server/pkg/agent" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" - "github.com/multica-ai/multica/server/pkg/taskfailure" ) type AgentRuntimeResponse struct { @@ -40,10 +39,11 @@ type AgentRuntimeResponse struct { DeviceInfo string `json:"device_info"` Metadata any `json:"metadata"` OwnerID *string `json:"owner_id"` - // Visibility is "private" (default — only the owner can bind agents) or + // Visibility is "private" (owner plus explicit SharedWith grants) or // "public" (any workspace member can). See migration 083 and // canUseRuntimeForAgent. - Visibility string `json:"visibility"` + Visibility string `json:"visibility"` + SharedWith []string `json:"shared_with"` // ProfileID is set when this runtime is an instance of a custom // runtime_profile (MUL-3284); null for built-in runtimes. ProfileID *string `json:"profile_id"` @@ -75,6 +75,7 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse { Metadata: metadata, OwnerID: uuidToPtr(rt.OwnerID), Visibility: rt.Visibility, + SharedWith: runtimeSharedWithStrings(rt.SharedWith), ProfileID: uuidToPtr(rt.ProfileID), LastSeenAt: timestampToPtr(rt.LastSeenAt), CreatedAt: timestampToString(rt.CreatedAt), @@ -460,11 +461,14 @@ func (h *Handler) resolveViewingTZ(r *http.Request) string { // Only fields users may legitimately edit are listed; other runtime metadata // (provider, daemon_id, status…) flows in from the daemon and is read-only here. type UpdateAgentRuntimeRequest struct { - // Visibility flips a runtime between "private" (default — only the owner - // can bind agents) and "public" (any workspace member can). Runtime owner + // Visibility selects "private" (owner plus SharedWith grants) or + // "public" (any workspace member can). Runtime owner // only, gated by canSetRuntimeVisibility — narrower than the rest of this // request, which admins may also send. Visibility *string `json:"visibility,omitempty"` + // SharedWith replaces explicit workspace user grants while visibility is private. + SharedWith *[]string `json:"shared_with,omitempty"` + sharedWithIDs []pgtype.UUID // CustomName sets or clears a user-facing display override (MUL-4217). // An empty / whitespace-only string clears it (revert to the // daemon-proposed name). Owner / workspace admin only. @@ -514,34 +518,18 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) { return } - // Validate every field before any mutation so a bad value in one field - // can't leave a partially-applied PATCH. - var ( - newVisibility string - needVisibility bool - ) - if req.Visibility != nil { - v := *req.Visibility - if v != "private" && v != "public" { - writeError(w, http.StatusBadRequest, "visibility must be 'private' or 'public'") + if req.SharedWith != nil { + req.sharedWithIDs, ok = parseRuntimeSharedWith(w, *req.SharedWith) + if !ok { return } - // Only a real change is gated. An admin renaming a runtime through a - // PATCH-as-PUT client that echoes the unchanged visibility back is not - // trying to share anyone's machine, so that no-op is dropped rather - // than turned into a 403 — same tolerance UpdateAgent applies to - // resubmitted agent permissions. - if v != rt.Visibility { - if !canSetRuntimeVisibility(member, rt) { - writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility") - return - } - if h.refuseQoderManagedRuntime(w, r.Context(), rt, "the Qoder runtime is always shared with the workspace") { - return - } - newVisibility = v - needVisibility = true - } + } + + // Validate every field before any mutation so a bad value in one field + // can't leave a partially-applied PATCH. + if req.Visibility != nil && *req.Visibility != "private" && *req.Visibility != "public" { + writeError(w, http.StatusBadRequest, "visibility must be 'private' or 'public'") + return } if req.CustomName != nil { if len([]rune(strings.TrimSpace(*req.CustomName))) > maxRuntimeCustomNameLen { @@ -552,18 +540,22 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) { changed := false - // The no-dependencies fast path must lock and re-check before changing - // visibility. A plain read followed by UPDATE would leave a narrow window - // where another member could bind an agent to the still-public runtime. - if needVisibility && rt.Visibility == "public" && newVisibility == "private" { - updated, plan, didChange, err := h.makeRuntimePrivateIfNoWorkspaceDependencies(r.Context(), rt, member) + if req.Visibility != nil || req.SharedWith != nil { + updated, plan, didChange, err := h.updateRuntimeAccessIfNoDependencies(r.Context(), rt, member, req) if err != nil { - if errors.Is(err, errRuntimeOwnerChanged) { - writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility") + if writeRuntimeRevocationBusy(w, err) { return } - slog.Error("make runtime private without dependencies failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to check runtime dependencies") + if errors.Is(err, errRuntimeOwnerChanged) { + writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility") + } else if errors.Is(err, errRuntimeManagedByConnection) { + writeJSON(w, http.StatusConflict, map[string]any{"error": "the Qoder runtime is always shared with the workspace", "code": "runtime_managed_by_connection"}) + } else if errors.Is(err, errInvalidRuntimeShare) { + writeError(w, http.StatusBadRequest, err.Error()) + } else { + slog.Error("update runtime access failed", "error", err, "runtime_id", runtimeID) + writeError(w, http.StatusInternalServerError, "failed to update runtime access") + } return } if plan.hasDependencies() { @@ -571,24 +563,9 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) { return } rt = updated - needVisibility = false changed = didChange } - if needVisibility { - updated, err := h.Queries.UpdateAgentRuntimeVisibility(r.Context(), db.UpdateAgentRuntimeVisibilityParams{ - ID: runtimeUUID, - Visibility: newVisibility, - }) - if err != nil { - slog.Error("UpdateAgentRuntimeVisibility failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to update runtime") - return - } - rt = updated - changed = true - } - if req.CustomName != nil { // An empty / whitespace-only name clears the override (NULL), so the // runtime falls back to its daemon-proposed Name. @@ -682,7 +659,7 @@ func (h *Handler) runtimeLookup(source string) service.RuntimeLookup { } // runtimeWorkspaceAccessRevocationPlan is the owner-visible snapshot of work -// that a public-to-private transition must revoke. It intentionally excludes +// that an access restriction must revoke. It intentionally excludes // the runtime owner's agents. Hidden Builder agents owned by teammates also // represent work borrowing the owner's machine and must lose access. type runtimeWorkspaceAccessRevocationPlan struct { @@ -708,14 +685,13 @@ func (p runtimeWorkspaceAccessRevocationPlan) hasDependencies() bool { return len(p.Agents) > 0 || len(p.Tasks) > 0 } -// makeRuntimePrivateIfNoWorkspaceDependencies handles the non-disruptive -// public-to-private path. Binding writers take the same runtime lock and -// recheck visibility before inserting/updating their agent. +// updateRuntimeAccessIfNoDependencies applies grants without disrupting work. +// Binding writers take the same runtime lock and recheck access. // // A non-empty plan is deliberately returned without committing. The caller // turns it into the same confirmation response used by the disruptive path; // the confirmed endpoint will take a fresh locked snapshot before revoking. -func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Context, rt db.AgentRuntime, member db.Member) (db.AgentRuntime, runtimeWorkspaceAccessRevocationPlan, bool, error) { +func (h *Handler) updateRuntimeAccessIfNoDependencies(ctx context.Context, rt db.AgentRuntime, member db.Member, req UpdateAgentRuntimeRequest) (db.AgentRuntime, runtimeWorkspaceAccessRevocationPlan, bool, error) { tx, err := h.TxStarter.Begin(ctx) if err != nil { return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("begin transaction: %w", err) @@ -727,37 +703,51 @@ func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Contex if err != nil { return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("lock runtime: %w", err) } + next, err := resolveRuntimeAccess(ctx, qtx, lockedRuntime, req) + if err != nil { + return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, err + } + if sameRuntimeAccess(lockedRuntime, next) { + return lockedRuntime, runtimeWorkspaceAccessRevocationPlan{}, false, nil + } if !canSetRuntimeVisibility(member, lockedRuntime) { return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, errRuntimeOwnerChanged } - // Another request may have made the runtime private while this PATCH was - // waiting for its lock. The requested end state is already satisfied. - if lockedRuntime.Visibility != "public" { - return lockedRuntime, runtimeWorkspaceAccessRevocationPlan{}, false, nil + managed, err := h.qoderManagesRuntime(ctx, lockedRuntime) + if err != nil { + return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, err + } + if managed { + return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, errRuntimeManagedByConnection } - agents, err := qtx.ListNonOwnerAgentsByRuntime(ctx, db.ListNonOwnerAgentsByRuntimeParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - }) - if err != nil { - return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner agents: %w", err) - } - tasks, err := qtx.ListNonOwnerActiveTasksByRuntime(ctx, db.ListNonOwnerActiveTasksByRuntimeParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - }) - if err != nil { - return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner tasks: %w", err) - } - plan := runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks} - if plan.hasDependencies() { - return lockedRuntime, plan, false, nil + if runtimeAccessNarrows(lockedRuntime, next) { + agents, err := qtx.ListNonOwnerAgentsByRuntime(ctx, db.ListNonOwnerAgentsByRuntimeParams{ + RuntimeID: lockedRuntime.ID, + RuntimeOwnerID: lockedRuntime.OwnerID, + SharedWith: next.SharedWith, + }) + if err != nil { + return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner agents: %w", err) + } + tasks, err := qtx.ListNonOwnerActiveTasksByRuntime(ctx, db.ListNonOwnerActiveTasksByRuntimeParams{ + RuntimeID: lockedRuntime.ID, + RuntimeOwnerID: lockedRuntime.OwnerID, + SharedWith: next.SharedWith, + }) + if err != nil { + return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner tasks: %w", err) + } + plan := runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks} + if plan.hasDependencies() { + return lockedRuntime, plan, false, nil + } } updated, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{ ID: lockedRuntime.ID, - Visibility: "private", + Visibility: next.Visibility, + SharedWith: next.SharedWith, }) if err != nil { return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("update runtime visibility: %w", err) @@ -765,7 +755,7 @@ func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Contex if err := tx.Commit(ctx); err != nil { return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("commit transaction: %w", err) } - return updated, plan, true, nil + return updated, runtimeWorkspaceAccessRevocationPlan{}, true, nil } // runtimeWorkspaceAccessRevocationResponse is returned for both the initial @@ -789,7 +779,7 @@ func (h *Handler) runtimeWorkspaceAccessRevocationResponse(plan runtimeWorkspace taskIDs[i] = uuidToString(task.ID) } return map[string]any{ - "error": "making this runtime private will unbind teammates' agents, cancel their active runs, and pause their active autopilots; review and confirm first.", + "error": "restricting runtime access will unbind affected agents, cancel their active runs, and pause their active autopilots; review and confirm first.", "code": code, "nonowner_agents": agents, "active_task_ids": taskIDs, @@ -799,7 +789,7 @@ func (h *Handler) runtimeWorkspaceAccessRevocationResponse(plan runtimeWorkspace // requireRuntimeReadAccess protects runtime data and machine-triggering // capabilities. Governance access is deliberately separate: workspace owners // and admins may list, rename, or delete another member's private runtime, -// but a private machine is readable or usable only by its owner. Returning +// but a private machine is readable or usable only with its owner's consent. Returning // 404 for that case prevents a known runtime ID from becoming an oracle. // // source names the product behaviour behind the read for @@ -1001,35 +991,16 @@ func (h *Handler) profileInstanceRefusalBlockers(ctx context.Context, runtimeID return profileInstanceBlockers{agents: agents, undrainedTasks: tasks, known: true} } -// canUseRuntimeForAgent reports whether a workspace member is allowed to -// bind a new agent to — or move an existing agent onto — the given runtime. -// A `public` runtime is usable by anyone in the workspace; a `private` one is -// usable only by its owner, with NO workspace owner/admin override (MUL-6126): -// a private runtime is someone's own machine, and running an agent on it -// spends their credentials and their local files, which is not an -// administrative decision. Sharing it is the owner's call, which is why -// canSetRuntimeVisibility is owner-only too — otherwise an admin would keep -// the same override with one extra step. -// -// An ownerless runtime is refused whatever its visibility: task claim needs an -// owner to mint the agent's task token and cancels the task without one -// (MUL-3292), so binding to one only produces agents that fail at run time. -// -// This is the same rule the clients enforce (`isRuntimeUsableForUser` in -// packages/core/runtimes/access.ts), so UI, API and CLI agree. See migration -// 083 for the visibility column. +// canUseRuntimeForAgent checks runtime-owner consent after workspace membership. +// Private runtimes admit their owner and SharedWith recipients; public runtimes +// admit every workspace member. Workspace roles never override this decision. +// An ownerless runtime cannot mint task tokens and remains unusable. func canUseRuntimeForAgent(member db.Member, rt db.AgentRuntime) bool { - if !rt.OwnerID.Valid { - return false - } - if rt.Visibility == "public" { - return true - } - return uuidToString(rt.OwnerID) == uuidToString(member.UserID) + return service.RuntimeAllowsUser(rt, member.UserID) } // canSetRuntimeVisibility reports whether a member may flip this runtime -// between `private` and `public`. Owner-only, and deliberately narrower than +// between `private`, selected members, and `public`. Deliberately narrower than // canEditRuntime: visibility is the owner's consent to lend their machine to // the workspace, so an admin who could flip it would still hold the override // canUseRuntimeForAgent removed. Admins keep canEditRuntime for the @@ -1042,10 +1013,11 @@ func canSetRuntimeVisibility(member db.Member, rt db.AgentRuntime) bool { type revokeRuntimeWorkspaceAccessRequest struct { ExpectedNonownerAgentIDs []string `json:"expected_nonowner_agent_ids"` ExpectedTaskIDs []string `json:"expected_task_ids"` + SharedWith []string `json:"shared_with"` } -// RevokeRuntimeWorkspaceAccess confirms the disruptive half of changing a -// public runtime back to private. The normal PATCH endpoint remains a direct +// RevokeRuntimeWorkspaceAccess confirms a restriction to private or selected +// members. Omitted shared_with preserves the old owner-only request contract. The normal PATCH endpoint remains a direct // update when no teammate is using the runtime; otherwise it returns a // structured 409 and this endpoint applies the reviewed plan atomically. // @@ -1065,6 +1037,11 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "invalid request body") return } + sharedWithIDs, ok := parseRuntimeSharedWith(w, req.SharedWith) + if !ok { + return + } + expectedAgents, ok := parseExpectedUUIDIDs(req.ExpectedNonownerAgentIDs) if !ok { writeError(w, http.StatusBadRequest, "expected_nonowner_agent_ids must be a list of valid UUIDs") @@ -1110,103 +1087,57 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility") return } - if lockedRuntime.Visibility != "public" { + private := "private" + next, err := resolveRuntimeAccess(r.Context(), qtx, lockedRuntime, UpdateAgentRuntimeRequest{Visibility: &private, SharedWith: &req.SharedWith, sharedWithIDs: sharedWithIDs}) + if err != nil { + if writeRuntimeRevocationBusy(w, err) { + return + } + if errors.Is(err, errInvalidRuntimeShare) { + writeError(w, http.StatusBadRequest, err.Error()) + } else { + writeError(w, http.StatusInternalServerError, "failed to validate runtime access") + } + return + } + if h.refuseQoderManagedRuntime(w, r.Context(), lockedRuntime, "the Qoder runtime is always shared with the workspace") { + return + } + if sameRuntimeAccess(lockedRuntime, next) { writeJSON(w, http.StatusConflict, map[string]any{ - "error": "this runtime is already private; refresh before trying again.", + "error": "runtime access already matches; refresh before trying again.", "code": "runtime_access_revocation_not_required", }) return } - currentAgents, err := qtx.ListNonOwnerAgentsByRuntimeForUpdate(r.Context(), db.ListNonOwnerAgentsByRuntimeForUpdateParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - }) + plan, err := lockRuntimeRevocationPlan(r.Context(), qtx, next) if err != nil { - if writeRuntimeRevocationBusy(w, err) { - return - } - writeError(w, http.StatusInternalServerError, "failed to lock runtime agents") - return - } - if _, err := qtx.LockRuntimeRevocationChatSessions(r.Context(), db.LockRuntimeRevocationChatSessionsParams{ - RuntimeID: lockedRuntime.ID, RuntimeOwnerID: lockedRuntime.OwnerID, - }); err != nil { if !writeRuntimeRevocationBusy(w, err) { - writeError(w, http.StatusInternalServerError, "failed to lock runtime chats") + writeError(w, http.StatusInternalServerError, "failed to lock runtime dependencies") } return } - currentTasks, err := qtx.ListNonOwnerActiveTasksByRuntimeForUpdate(r.Context(), db.ListNonOwnerActiveTasksByRuntimeForUpdateParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - }) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to lock runtime tasks") - return - } - plan := runtimeWorkspaceAccessRevocationPlan{Agents: currentAgents, Tasks: currentTasks} - if !agentSetMatches(currentAgents, expectedAgents) || !taskSetMatches(currentTasks, expectedTasks) { + + if !agentSetMatches(plan.Agents, expectedAgents) || !taskSetMatches(plan.Tasks, expectedTasks) { body := h.runtimeWorkspaceAccessRevocationResponse(plan, "runtime_access_revocation_plan_changed") body["error"] = "the runtime access plan changed; please review and confirm again." writeJSON(w, http.StatusConflict, body) return } - updated, err := qtx.UpdateAgentRuntimeVisibility(r.Context(), db.UpdateAgentRuntimeVisibilityParams{ - ID: lockedRuntime.ID, - Visibility: "private", - }) + updated, result, readyChats, err := applyRuntimeAccessRevocation(r.Context(), qtx, next) if err != nil { - slog.Error("make runtime private failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to update runtime") - return - } - cancelled, err := qtx.CancelNonOwnerTasksByRuntime(r.Context(), db.CancelNonOwnerTasksByRuntimeParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - FailureReason: string(taskfailure.ReasonRuntimeAccessRevoked), - }) - if err != nil { - slog.Error("cancel non-owner runtime tasks failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to cancel runtime tasks") - return - } - readyChats, err := service.PrepareRuntimeAccessCancellation(r.Context(), qtx, cancelled) - if err != nil { - slog.Error("prepare runtime cancellation chat settlement failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to settle runtime chats") - return - } - unbound, err := qtx.UnbindNonOwnerAgentsFromRuntime(r.Context(), db.UnbindNonOwnerAgentsFromRuntimeParams{ - RuntimeID: lockedRuntime.ID, - RuntimeOwnerID: lockedRuntime.OwnerID, - }) - if err != nil { - slog.Error("unbind non-owner runtime agents failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to unbind runtime agents") - return - } - unboundIDs := make([]pgtype.UUID, len(unbound)) - for i, agent := range unbound { - unboundIDs[i] = agent.ID - } - paused, err := qtx.PauseAutopilotsByUnboundAgents(r.Context(), unboundIDs) - if err != nil { - slog.Error("pause autopilots for runtime privacy failed", "error", err, "runtime_id", runtimeID) - writeError(w, http.StatusInternalServerError, "failed to pause affected autopilots") + slog.Error("revoke runtime access failed", "error", err, "runtime_id", runtimeID) + writeError(w, http.StatusInternalServerError, "failed to revoke runtime access") return } + if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to commit runtime privacy change") return } - result := service.RuntimeTeardownResult{ - UnboundAgents: unbound, - CancelledTasks: cancelled, - PausedAutopilots: paused, - } h.PublishRuntimeTeardown(r.Context(), result, wsID, "member", uuidToString(member.UserID), "update", true) for _, taskID := range readyChats { h.TaskService.FinalizeDeferredCancelledChat(r.Context(), taskID) @@ -1214,16 +1145,16 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re slog.Info("runtime made private and workspace access revoked", "runtime_id", runtimeID, "changed_by", uuidToString(member.UserID), - "agents_unbound", len(unbound), - "tasks_cancelled", len(cancelled), - "autopilots_paused", len(paused), + "agents_unbound", len(result.UnboundAgents), + "tasks_cancelled", len(result.CancelledTasks), + "autopilots_paused", len(result.PausedAutopilots), ) writeJSON(w, http.StatusOK, map[string]any{ "runtime": runtimeToResponse(updated), - "agents_unbound": len(unbound), - "tasks_cancelled": len(cancelled), - "autopilots_paused": len(paused), + "agents_unbound": len(result.UnboundAgents), + "tasks_cancelled": len(result.CancelledTasks), + "autopilots_paused": len(result.PausedAutopilots), }) } diff --git a/server/internal/handler/runtime_sharing.go b/server/internal/handler/runtime_sharing.go new file mode 100644 index 000000000..d9e8fedda --- /dev/null +++ b/server/internal/handler/runtime_sharing.go @@ -0,0 +1,148 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "sort" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/service" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/taskfailure" +) + +var errRuntimeManagedByConnection = errors.New("runtime managed by connection") + +var errInvalidRuntimeShare = errors.New("invalid runtime sharing") + +func parseRuntimeSharedWith(w http.ResponseWriter, raw []string) ([]pgtype.UUID, bool) { + if len(raw) > 1000 { + writeError(w, http.StatusBadRequest, "shared_with has too many members") + return nil, false + } + ids := make([]pgtype.UUID, 0, len(raw)) + for _, value := range raw { + id, ok := parseUUIDOrBadRequest(w, value, "shared_with") + if !ok { + return nil, false + } + ids = append(ids, id) + } + return ids, true +} + +func runtimeAccessNarrows(previous, next db.AgentRuntime) bool { + if next.Visibility == "public" { + return false + } + if previous.Visibility == "public" { + return true + } + for _, id := range previous.SharedWith { + if id != next.OwnerID && !slices.Contains(next.SharedWith, id) { + return true + } + } + return false +} + +func runtimeSharedWithStrings(ids []pgtype.UUID) []string { + result := make([]string, len(ids)) + for i, id := range ids { + result[i] = uuidToString(id) + } + return result +} + +func sameRuntimeAccess(a, b db.AgentRuntime) bool { + return a.Visibility == b.Visibility && slices.Equal(a.SharedWith, b.SharedWith) +} + +// Resolve PATCH fields from the locked row. Explicit private/public requests +// without a recipient list clear grants, preserving old clients' privacy action. +func resolveRuntimeAccess(ctx context.Context, q *db.Queries, rt db.AgentRuntime, req UpdateAgentRuntimeRequest) (db.AgentRuntime, error) { + next := rt + if req.Visibility != nil { + next.Visibility = *req.Visibility + next.SharedWith = []pgtype.UUID{} + } + if req.SharedWith != nil { + next.SharedWith = []pgtype.UUID{} + for _, id := range req.sharedWithIDs { + if id == rt.OwnerID || slices.Contains(next.SharedWith, id) { + continue + } + next.SharedWith = append(next.SharedWith, id) + } + sort.Slice(next.SharedWith, func(i, j int) bool { return uuidToString(next.SharedWith[i]) < uuidToString(next.SharedWith[j]) }) + } + if next.Visibility == "public" && len(next.SharedWith) > 0 { + return next, fmt.Errorf("%w: shared_with requires private visibility", errInvalidRuntimeShare) + } + if len(next.SharedWith) > 0 { + members, err := q.LockRuntimeShareMembers(ctx, db.LockRuntimeShareMembersParams{WorkspaceID: rt.WorkspaceID, UserIds: next.SharedWith}) + if err != nil { + return next, err + } + if len(members) != len(next.SharedWith) { + return next, fmt.Errorf("%w: every recipient must be a workspace member", errInvalidRuntimeShare) + } + } + return next, nil +} + +// Both explicit access changes and workspace departure use the same lock order +// and cancellation settlement, inside their caller's transaction. +func lockRuntimeRevocationPlan(ctx context.Context, qtx *db.Queries, next db.AgentRuntime) (runtimeWorkspaceAccessRevocationPlan, error) { + agents, err := qtx.ListNonOwnerAgentsByRuntimeForUpdate(ctx, db.ListNonOwnerAgentsByRuntimeForUpdateParams{ + RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith, + }) + if err != nil { + return runtimeWorkspaceAccessRevocationPlan{}, err + } + if _, err := qtx.LockRuntimeRevocationChatSessions(ctx, db.LockRuntimeRevocationChatSessionsParams{ + RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith, + }); err != nil { + return runtimeWorkspaceAccessRevocationPlan{}, err + } + tasks, err := qtx.ListNonOwnerActiveTasksByRuntimeForUpdate(ctx, db.ListNonOwnerActiveTasksByRuntimeForUpdateParams{ + RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith, + }) + return runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks}, err +} + +func applyRuntimeAccessRevocation(ctx context.Context, qtx *db.Queries, next db.AgentRuntime) (db.AgentRuntime, service.RuntimeTeardownResult, []pgtype.UUID, error) { + var result service.RuntimeTeardownResult + updated, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{ + ID: next.ID, Visibility: next.Visibility, SharedWith: next.SharedWith, + }) + if err != nil { + return updated, result, nil, err + } + result.CancelledTasks, err = qtx.CancelNonOwnerTasksByRuntime(ctx, db.CancelNonOwnerTasksByRuntimeParams{ + RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith, + FailureReason: string(taskfailure.ReasonRuntimeAccessRevoked), + }) + if err != nil { + return updated, result, nil, err + } + readyChats, err := service.PrepareRuntimeAccessCancellation(ctx, qtx, result.CancelledTasks) + if err != nil { + return updated, result, nil, err + } + result.UnboundAgents, err = qtx.UnbindNonOwnerAgentsFromRuntime(ctx, db.UnbindNonOwnerAgentsFromRuntimeParams{ + RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith, + }) + if err != nil { + return updated, result, nil, err + } + ids := make([]pgtype.UUID, len(result.UnboundAgents)) + for i, a := range result.UnboundAgents { + ids[i] = a.ID + } + result.PausedAutopilots, err = qtx.PauseAutopilotsByUnboundAgents(ctx, ids) + return updated, result, readyChats, err +} diff --git a/server/internal/handler/runtime_sharing_test.go b/server/internal/handler/runtime_sharing_test.go new file mode 100644 index 000000000..ce9e5819b --- /dev/null +++ b/server/internal/handler/runtime_sharing_test.go @@ -0,0 +1,208 @@ +package handler + +import ( + "context" + "fmt" + "github.com/google/uuid" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "net/http" + "testing" + + "github.com/multica-ai/multica/server/internal/testutil" +) + +func runtimeSharingPatch(t *testing.T, runtimeID, actorID string, body map[string]any) *testutil.Response { + t.Helper() + req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, body) + return testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", runtimeID)) +} + +func TestRuntimeSharingAccess(t *testing.T) { + if testHandler == nil { + t.Fatal("database-backed fixture required") + } + rtID, ownerID, memberID := runtimeVisibilityFixture(t) + var runtime AgentRuntimeResponse + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID, memberID, ownerID}}).Want(http.StatusOK).JSON(&runtime) + if runtime.Visibility != "private" || len(runtime.SharedWith) != 1 || runtime.SharedWith[0] != memberID { + t.Fatalf("unexpected grant: %+v", runtime) + } + var visible []AgentRuntimeResponse + testutil.Call(t, testHandler.ListAgentRuntimes, newRequestAs(memberID, http.MethodGet, "/api/runtimes", nil)).Want(http.StatusOK).JSON(&visible) + found := false + for _, rt := range visible { + found = found || rt.ID == rtID + } + if !found { + t.Fatal("shared runtime missing from recipient list") + } + for _, tc := range []struct { + actor string + status int + }{{ownerID, 200}, {memberID, 200}, {testUserID, 404}} { + req := newRequestAs(tc.actor, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil) + testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(tc.status) + } + for _, actor := range []string{memberID, testUserID} { + runtimeSharingPatch(t, rtID, actor, map[string]any{"shared_with": []string{testUserID}}).Want(http.StatusForbidden) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, rtID) }) + testutil.Call(t, testHandler.CreateAgent, newRequestAs(memberID, http.MethodPost, "/api/agents", map[string]any{ + "name": "Shared runtime recipient", "runtime_id": rtID, + })).Want(http.StatusCreated) + testutil.Call(t, testHandler.CreateAgent, newRequestAs(testUserID, http.MethodPost, "/api/agents", map[string]any{ + "name": "Unselected runtime admin", "runtime_id": rtID, + })).Want(http.StatusForbidden) +} + +func TestRuntimeSharingRejectsInvalidRecipients(t *testing.T) { + if testHandler == nil { + t.Fatal("database-backed fixture required") + } + rtID, ownerID, _ := runtimeVisibilityFixture(t) + outsider := dbfx.Insert(t, "user", testutil.Cols{"name": "Outside workspace", "email": "runtime-sharing-outside@multica.test"}) + for _, recipients := range [][]string{{"bad-uuid"}, {outsider}} { + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": recipients, "custom_name": "must not save"}).Want(http.StatusBadRequest) + } + req := newRequestAs(outsider, http.MethodPatch, "/api/runtimes/"+rtID, map[string]any{"shared_with": []string{outsider}}) + testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound) + rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID)) + if err != nil || len(rt.SharedWith) != 0 || rt.CustomName.Valid { + t.Fatalf("invalid input partially saved: %+v, %v", rt, err) + } +} + +func TestRuntimeSharingRevokesOnlyRemovedMembers(t *testing.T) { + if testHandler == nil { + t.Fatal("database-backed fixture required") + } + for _, visibility := range []string{"public", "private"} { + t.Run(visibility, func(t *testing.T) { + rtID, ownerID, removedID := runtimeVisibilityFixture(t) + dbfx.Exec(t, `UPDATE agent_runtime SET visibility = $2, shared_with = ARRAY[$3::uuid, $4::uuid] WHERE id = $1`, rtID, visibility, removedID, testUserID) + removedAgent := createRuntimePrivacyAgent(t, rtID, removedID, "Removed member agent") + keptAgent := createRuntimePrivacyAgent(t, rtID, testUserID, "Kept member agent") + ownAgent := createRuntimePrivacyAgent(t, rtID, ownerID, "Runtime owner agent") + removedTask := dbfx.Task(t, removedAgent, testutil.Cols{"runtime_id": rtID, "status": "running"}) + keptTask := dbfx.Task(t, keptAgent, testutil.Cols{"runtime_id": rtID, "status": "running"}) + var plan struct { + Agents []struct { + ID string `json:"id"` + } `json:"nonowner_agents"` + Tasks []string `json:"active_task_ids"` + } + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private", "shared_with": []string{testUserID}}).Want(http.StatusConflict).JSON(&plan) + if len(plan.Agents) != 1 || plan.Agents[0].ID != removedAgent || len(plan.Tasks) != 1 || plan.Tasks[0] != removedTask { + t.Fatalf("wrong revocation plan: %+v", plan) + } + runtimePrivacyRequest(t, rtID, ownerID, map[string]any{ + "shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{}, + }).Want(http.StatusConflict) + runtimePrivacyRequest(t, rtID, ownerID, map[string]any{ + "shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{removedTask}, + }).Want(http.StatusOK) + for _, agentID := range []string{keptAgent, ownAgent} { + a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID)) + if err != nil || a.RuntimeID != mustTestUUID(t, rtID) { + t.Fatalf("kept agent lost binding: %+v %v", a, err) + } + } + for _, tc := range []struct{ id, status string }{{removedTask, "cancelled"}, {keptTask, "running"}} { + task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, tc.id)) + if err != nil || task.Status != tc.status { + t.Fatalf("task status=%s, want %s, err=%v", task.Status, tc.status, err) + } + } + a, _ := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, removedAgent)) + if a.RuntimeID.Valid { + t.Fatal("removed agent remains bound") + } + req := newRequestAs(removedID, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil) + testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound) + }) + } +} + +func TestRuntimeSharingOldClientPrivateClearsGrants(t *testing.T) { + rtID, ownerID, memberID := runtimeVisibilityFixture(t) + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK) + var rt AgentRuntimeResponse + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private"}).Want(http.StatusOK).JSON(&rt) + if len(rt.SharedWith) != 0 { + t.Fatal("explicit old-client private request must clear grants") + } +} + +func TestRuntimeSharingMemberRemovalClearsGrant(t *testing.T) { + rtID, ownerID, memberID := runtimeVisibilityFixture(t) + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK) + agentID := createRuntimePrivacyAgent(t, rtID, memberID, "Departing member agent") + taskID := dbfx.Task(t, agentID, testutil.Cols{"runtime_id": rtID, "status": "running"}) + + member, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{ + WorkspaceID: mustTestUUID(t, testWorkspaceID), UserID: mustTestUUID(t, memberID), + }) + if err != nil { + t.Fatal(err) + } + req := newRequest(http.MethodDelete, "/api/workspaces/"+testWorkspaceID+"/members/"+uuidToString(member.ID), nil) + testutil.Call(t, testHandler.DeleteMember, testutil.WithURLParams(req, "id", testWorkspaceID, "memberId", uuidToString(member.ID))).Want(http.StatusNoContent) + rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID)) + if err != nil || len(rt.SharedWith) != 0 { + t.Fatalf("departed member retained grant: %+v %v", rt.SharedWith, err) + } + task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, taskID)) + if err != nil || task.Status != "cancelled" { + t.Fatalf("departed member's run survived: %+v %v", task, err) + } + a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID)) + if err != nil || a.RuntimeID.Valid { + t.Fatalf("departed member's agent remains bound: %+v %v", a, err) + } + +} + +func TestRuntimeSharingClaimAndReclaim(t *testing.T) { + for _, reclaim := range []bool{false, true} { + t.Run(fmt.Sprint("reclaim=", reclaim), func(t *testing.T) { + ctx := context.Background() + memberID := dbfx.User(t, "Shared claim member", "shared-claim-"+uuid.NewString()+"@example.com") + dbfx.Member(t, testWorkspaceID, memberID, "member") + runtimeID := createClaimReclaimRuntime(t, ctx, "Shared claim runtime") + agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Shared claim agent") + dbfx.Exec(t, `UPDATE agent SET owner_id = $1 WHERE id = $2`, memberID, agentID) + runtimeSharingPatch(t, runtimeID, testUserID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK) + taskID := seedQueuedIssueTask(t, ctx, agentID, runtimeID, issueID) + if reclaim { + dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() - interval '10 minutes', prepare_lease_expires_at = NULL WHERE id = $1`, taskID) + } + req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil) + var body struct { + Task *struct { + ID string `json:"id"` + } `json:"task"` + } + testutil.Call(t, testHandler.ClaimTaskByRuntime, withURLParam(req, "runtimeId", runtimeID)).Want(http.StatusOK).JSON(&body) + if body.Task == nil || body.Task.ID != taskID { + t.Fatalf("shared member task not delivered: %+v", body.Task) + } + }) + } +} + +func TestRuntimeSharingMemberRemovalContention(t *testing.T) { + rtID, ownerID, memberID := runtimeVisibilityFixture(t) + tx, err := testPool.Begin(context.Background()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err := tx.Exec(context.Background(), `SELECT id FROM member WHERE workspace_id = $1 AND user_id = $2 FOR UPDATE`, testWorkspaceID, memberID); err != nil { + t.Fatal(err) + } + runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusConflict) + rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID)) + if err != nil || len(rt.SharedWith) != 0 { + t.Fatal("busy grant partially persisted") + } +} diff --git a/server/internal/handler/workspace_revoke.go b/server/internal/handler/workspace_revoke.go index c02b3eb69..2d2733f94 100644 --- a/server/internal/handler/workspace_revoke.go +++ b/server/internal/handler/workspace_revoke.go @@ -3,6 +3,7 @@ package handler import ( "context" "log/slog" + "slices" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -65,6 +66,12 @@ func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID return empty, err } + if _, err := qtx.LockRuntimeShareMemberRemoval(ctx, db.LockRuntimeShareMemberRemovalParams{ + WorkspaceID: workspaceID, UserID: userID, + }); err != nil { + return empty, err + } + runtimes, err := qtx.ListAgentRuntimesByOwner(ctx, db.ListAgentRuntimesByOwnerParams{ WorkspaceID: workspaceID, OwnerID: userID, @@ -220,6 +227,36 @@ func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID return empty, err } } + sharedRuntimes, err := qtx.ListSharedAgentRuntimesForUpdate(ctx, db.ListSharedAgentRuntimesForUpdateParams{ + WorkspaceID: workspaceID, UserID: userID, + }) + if err != nil { + return empty, err + } + result.SharedRuntimeCount = len(sharedRuntimes) + for _, rt := range sharedRuntimes { + rt.SharedWith = slices.DeleteFunc(rt.SharedWith, func(id pgtype.UUID) bool { return id == userID }) + if rt.Visibility == "public" { + if _, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{ + ID: rt.ID, Visibility: rt.Visibility, SharedWith: rt.SharedWith, + }); err != nil { + return empty, err + } + continue + } + if _, err := lockRuntimeRevocationPlan(ctx, qtx, rt); err != nil { + return empty, err + } + _, teardown, readyChats, err := applyRuntimeAccessRevocation(ctx, qtx, rt) + if err != nil { + return empty, err + } + result.SharedTeardown.UnboundAgents = append(result.SharedTeardown.UnboundAgents, teardown.UnboundAgents...) + result.SharedTeardown.CancelledTasks = append(result.SharedTeardown.CancelledTasks, teardown.CancelledTasks...) + result.SharedTeardown.PausedAutopilots = append(result.SharedTeardown.PausedAutopilots, teardown.PausedAutopilots...) + result.ReadyChats = append(result.ReadyChats, readyChats...) + } + if err := qtx.DeleteMember(ctx, memberID); err != nil { return empty, err } @@ -241,10 +278,13 @@ type revocationResult struct { CancelledTasks []db.AgentTaskQueue OfflineRuntimeIDs []db.ForceOfflineRuntimesByIDsRow RevokedTokenHashes []string + SharedRuntimeCount int + SharedTeardown service.RuntimeTeardownResult + ReadyChats []pgtype.UUID } func (r revocationResult) isEmpty() bool { - return len(r.Runtimes) == 0 + return len(r.Runtimes) == 0 && r.SharedRuntimeCount == 0 } // publishRevocation runs all post-commit side effects: invalidate daemon token @@ -277,12 +317,19 @@ func (h *Handler) publishRevocation(ctx context.Context, result revocationResult }) } + h.PublishRuntimeTeardown(ctx, result.SharedTeardown, workspaceIDStr, actorType, actorIDStr, "revoke", false) + if h.TaskService != nil { + for _, taskID := range result.ReadyChats { + h.TaskService.FinalizeDeferredCancelledChat(ctx, taskID) + } + } + // Tell connected clients to refresh the runtime list. We piggyback on // EventDaemonRegister with a "revoke" action — same channel the runtime // delete handler uses — so the frontend invalidates its cached list // without us having to introduce a new event type the desktop app would // need a build to learn about. - if len(result.OfflineRuntimeIDs) > 0 { + if len(result.OfflineRuntimeIDs) > 0 || result.SharedRuntimeCount > 0 { h.publish(protocol.EventDaemonRegister, workspaceIDStr, actorType, actorIDStr, map[string]any{ "action": "revoke", }) diff --git a/server/internal/service/agent_ready.go b/server/internal/service/agent_ready.go index b65f72210..c849a1856 100644 --- a/server/internal/service/agent_ready.go +++ b/server/internal/service/agent_ready.go @@ -154,11 +154,11 @@ func AgentReadiness(ctx context.Context, lookup RuntimeLookup, agent db.Agent) ( // runtimeVerdict combines runtime health with the ownership binding that // determines whether this agent can execute there. func runtimeVerdict(rt db.AgentRuntime, agent db.Agent) AgentVerdict { - if rt.Visibility == "private" && rt.OwnerID.Valid && (!agent.OwnerID.Valid || agent.OwnerID != rt.OwnerID) { + if rt.Visibility == "private" && rt.OwnerID.Valid && !RuntimeAllowsUser(rt, agent.OwnerID) { return AgentVerdict{ Availability: AgentBlocked, Reason: dispatch.ReasonRuntimeAccessDenied, - Detail: "agent owner does not match private runtime owner", + Detail: "agent owner does not have access to the private runtime", } } if rt.Status == "online" { @@ -269,7 +269,7 @@ func RuntimeUnusableNotice(agentName string, verdict AgentVerdict) string { } if verdict.Reason == dispatch.ReasonRuntimeAccessDenied { return fmt.Sprintf( - "%s cannot run on this private runtime, so this trigger was not queued. Make the runtime's machine public, or rebind/copy the agent to a runtime its owner can use.", + "%s cannot run on this private runtime, so this trigger was not queued. Ask its owner to share the runtime with the agent owner or make it public, or rebind/copy the agent to a runtime its owner can use.", name, ) } diff --git a/server/internal/service/runtime_access.go b/server/internal/service/runtime_access.go new file mode 100644 index 000000000..5c8a4d598 --- /dev/null +++ b/server/internal/service/runtime_access.go @@ -0,0 +1,15 @@ +package service + +import ( + "slices" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// RuntimeAllowsUser evaluates machine-owner consent after workspace membership +// has been checked by the caller. Sharing never grants machine administration. +func RuntimeAllowsUser(rt db.AgentRuntime, userID pgtype.UUID) bool { + return rt.OwnerID.Valid && userID.Valid && (rt.OwnerID == userID || + rt.Visibility == "public" || (rt.Visibility == "private" && slices.Contains(rt.SharedWith, userID))) +} diff --git a/server/internal/service/runtime_access_test.go b/server/internal/service/runtime_access_test.go new file mode 100644 index 000000000..f8df2ed12 --- /dev/null +++ b/server/internal/service/runtime_access_test.go @@ -0,0 +1,35 @@ +package service + +import ( + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "testing" +) + +func TestRuntimeSharedMemberReadiness(t *testing.T) { + owner := util.MustParseUUID("11111111-1111-4111-8111-111111111111") + member := util.MustParseUUID("22222222-2222-4222-8222-222222222222") + other := util.MustParseUUID("33333333-3333-4333-8333-333333333333") + rt := db.AgentRuntime{OwnerID: owner, Visibility: "private", SharedWith: []pgtype.UUID{member}, Status: "online"} + for _, tc := range []struct { + id pgtype.UUID + want bool + }{{owner, true}, {member, true}, {other, false}, {pgtype.UUID{}, false}} { + if got := RuntimeAllowsUser(rt, tc.id); got != tc.want { + t.Fatalf("access=%v want %v", got, tc.want) + } + if got := runtimeVerdict(rt, db.Agent{OwnerID: tc.id}).Ready(); got != tc.want { + t.Fatalf("ready=%v want %v", got, tc.want) + } + } + rt.SharedWith = nil + if runtimeVerdict(rt, db.Agent{OwnerID: member}).Ready() { + t.Fatal("removed member remains ready") + } + rt.SharedWith = []pgtype.UUID{member} + rt.OwnerID = pgtype.UUID{} + if RuntimeAllowsUser(rt, member) { + t.Fatal("ownerless runtime grants access") + } +} diff --git a/server/migrations/592_runtime_shared_with.down.sql b/server/migrations/592_runtime_shared_with.down.sql new file mode 100644 index 000000000..fb1d87ff2 --- /dev/null +++ b/server/migrations/592_runtime_shared_with.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent_runtime DROP COLUMN IF EXISTS shared_with; diff --git a/server/migrations/592_runtime_shared_with.up.sql b/server/migrations/592_runtime_shared_with.up.sql new file mode 100644 index 000000000..fb4204257 --- /dev/null +++ b/server/migrations/592_runtime_shared_with.up.sql @@ -0,0 +1,2 @@ +-- Explicit workspace user grants keep visibility private for older servers. +ALTER TABLE agent_runtime ADD COLUMN shared_with uuid[] NOT NULL DEFAULT '{}'; diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index ac898986b..99414e592 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -5664,20 +5664,21 @@ const listNonOwnerAgentsByRuntime = `-- name: ListNonOwnerAgentsByRuntime :many SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters FROM agent WHERE runtime_id = $1 AND owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(owner_id = ANY($3::uuid[]), false) ORDER BY name ASC ` type ListNonOwnerAgentsByRuntimeParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } -// Returns every agent bound to a runtime whose owner differs from its owner, -// including hidden Builder execution carriers. This is the dependency plan shown before a public -// runtime is made private: owner-owned agents keep their binding, while every -// teammate-owned agent loses machine access. +// Returns bound agents whose owners lose access under the desired shared_with +// list, including hidden Builder execution carriers. Runtime-owner agents keep +// their binding. func (q *Queries) ListNonOwnerAgentsByRuntime(ctx context.Context, arg ListNonOwnerAgentsByRuntimeParams) ([]Agent, error) { - rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntime, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -5730,19 +5731,21 @@ const listNonOwnerAgentsByRuntimeForUpdate = `-- name: ListNonOwnerAgentsByRunti SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters FROM agent WHERE runtime_id = $1 AND owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(owner_id = ANY($3::uuid[]), false) ORDER BY id FOR UPDATE NOWAIT ` type ListNonOwnerAgentsByRuntimeForUpdateParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } // Runtime access-change locking serializes new bindings. NOWAIT avoids waiting // behind a writer that already holds an agent and needs the runtime row. func (q *Queries) ListNonOwnerAgentsByRuntimeForUpdate(ctx context.Context, arg ListNonOwnerAgentsByRuntimeForUpdateParams) ([]Agent, error) { - rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -7761,6 +7764,7 @@ WHERE id = ( r.owner_id IS NULL OR a.owner_id IS NULL OR r.owner_id = a.owner_id + OR a.owner_id = ANY(r.shared_with) ) ) ) @@ -7887,6 +7891,7 @@ WHERE id IN ( r.owner_id IS NULL OR a.owner_id IS NULL OR r.owner_id = a.owner_id + OR a.owner_id = ANY(r.shared_with) ) ) ) diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index fb908b92b..d544c0c01 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -98,6 +98,7 @@ type AgentRuntime struct { Visibility string `json:"visibility"` ProfileID pgtype.UUID `json:"profile_id"` CustomName pgtype.Text `json:"custom_name"` + SharedWith []pgtype.UUID `json:"shared_with"` } type AgentSkill struct { diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index 9d2b06332..201102593 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -140,14 +140,16 @@ FROM agent WHERE task.agent_id = agent.id AND task.runtime_id = $2 AND agent.owner_id IS DISTINCT FROM $3 + AND NOT COALESCE(agent.owner_id = ANY($4::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') RETURNING task.id, task.agent_id, task.issue_id, task.status, task.priority, task.dispatched_at, task.started_at, task.completed_at, task.result, task.error, task.created_at, task.context, task.runtime_id, task.session_id, task.work_dir, task.trigger_comment_id, task.chat_session_id, task.autopilot_run_id, task.attempt, task.max_attempts, task.parent_task_id, task.failure_reason, task.trigger_summary, task.force_fresh_session, task.is_leader_task, task.wait_reason, task.initiator_user_id, task.handoff_note, task.prepare_lease_expires_at, task.squad_id, task.runtime_mcp_overlay, task.escalation_for_task_id, task.fire_at, task.originator_user_id, task.runtime_connected_apps, task.coalesced_comment_ids, task.delivered_comment_ids, task.chat_input_task_id, task.chat_finalize_deferred_at, task.originator_source, task.delegated_from_task_id, task.retry_of_task_id, task.rerun_of_task_id, task.rule_version_id, task.trigger_evidence_kind, task.trigger_evidence_ref_id, task.accountable_user_id, task.session_rollout_missing, task.retired_session_id, task.quick_actions_disabled, task.regenerate_quick_actions_for, task.branch_name, task.durable_work_dir, task.channel_context_revision, task.comment_thread_id, task.cancelled_by_type, task.cancelled_by_id, task.cancelled_by_name, task.issue_snapshot, task.workflow_step ` type CancelNonOwnerTasksByRuntimeParams struct { - FailureReason string `json:"failure_reason"` - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + FailureReason string `json:"failure_reason"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } // Server-side execution revocation for a public runtime made private. The @@ -156,7 +158,12 @@ type CancelNonOwnerTasksByRuntimeParams struct { // cancellation polling on an online daemon observes the state change; an // offline daemon cannot run new work and will observe it after reconnecting. func (q *Queries) CancelNonOwnerTasksByRuntime(ctx context.Context, arg CancelNonOwnerTasksByRuntimeParams) ([]AgentTaskQueue, error) { - rows, err := q.db.Query(ctx, cancelNonOwnerTasksByRuntime, arg.FailureReason, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, cancelNonOwnerTasksByRuntime, + arg.FailureReason, + arg.RuntimeID, + arg.RuntimeOwnerID, + arg.SharedWith, + ) if err != nil { return nil, err } @@ -425,7 +432,7 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context, arg FailTasks } const findLegacyRuntimesByDaemonID = `-- name: FindLegacyRuntimesByDaemonID :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE workspace_id = $1 AND provider = $2 AND LOWER(daemon_id) = LOWER($3) @@ -479,6 +486,7 @@ func (q *Queries) FindLegacyRuntimesByDaemonID(ctx context.Context, arg FindLega &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -538,7 +546,7 @@ func (q *Queries) ForceOfflineRuntimesByIDs(ctx context.Context, runtimeIds []pg } const getAgentRuntime = `-- name: GetAgentRuntime :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE id = $1 ` @@ -563,12 +571,13 @@ func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRun &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } const getAgentRuntimeForWorkspace = `-- name: GetAgentRuntimeForWorkspace :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE id = $1 AND workspace_id = $2 ` @@ -598,6 +607,7 @@ func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentR &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } @@ -652,7 +662,7 @@ func (q *Queries) GetAgentRuntimeHeartbeatLeases(ctx context.Context, ids []pgty } const getAgentRuntimes = `-- name: GetAgentRuntimes :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE id = ANY($1::uuid[]) ` @@ -688,6 +698,7 @@ func (q *Queries) GetAgentRuntimes(ctx context.Context, ids []pgtype.UUID) ([]Ag &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -758,7 +769,7 @@ func (q *Queries) ListAgentRuntimeIDsByWorkspace(ctx context.Context, workspaceI } const listAgentRuntimes = `-- name: ListAgentRuntimes :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC ` @@ -790,6 +801,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -802,7 +814,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID } const listAgentRuntimesByOwner = `-- name: ListAgentRuntimesByOwner :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE workspace_id = $1 AND owner_id = $2 ORDER BY created_at ASC ` @@ -839,6 +851,7 @@ func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRun &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -896,21 +909,21 @@ FROM agent_task_queue AS task JOIN agent ON agent.id = task.agent_id WHERE task.runtime_id = $1 AND agent.owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ORDER BY task.id ` type ListNonOwnerActiveTasksByRuntimeParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } -// Lists live tasks targeting a public runtime but belonging to somebody other -// than its owner, including hidden Builder execution carriers. It forms part of the public-to-private -// confirmation plan so the owner explicitly approves cancelling work that -// could otherwise keep running after machine access is revoked. +// Lists live tasks whose agent owner will lose runtime access, including +// hidden Builder execution carriers. shared_with is the desired recipient list. func (q *Queries) ListNonOwnerActiveTasksByRuntime(ctx context.Context, arg ListNonOwnerActiveTasksByRuntimeParams) ([]AgentTaskQueue, error) { - rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntime, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -996,20 +1009,22 @@ FROM agent_task_queue AS task JOIN agent ON agent.id = task.agent_id WHERE task.runtime_id = $1 AND agent.owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ORDER BY task.id FOR UPDATE OF task ` type ListNonOwnerActiveTasksByRuntimeForUpdateParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } // Acquired after the affected agent and chat-session locks. Bindings serialize // on LockAgentRuntimeForAccessChange; claim/delivery rechecks authorization. func (q *Queries) ListNonOwnerActiveTasksByRuntimeForUpdate(ctx context.Context, arg ListNonOwnerActiveTasksByRuntimeForUpdateParams) ([]AgentTaskQueue, error) { - rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -1127,6 +1142,57 @@ func (q *Queries) ListRuntimeOwnersByDaemon(ctx context.Context, arg ListRuntime return items, nil } +const listSharedAgentRuntimesForUpdate = `-- name: ListSharedAgentRuntimesForUpdate :many +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime +WHERE workspace_id = $1 AND $2::uuid = ANY(shared_with) +ORDER BY id +FOR NO KEY UPDATE +` + +type ListSharedAgentRuntimesForUpdateParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) ListSharedAgentRuntimesForUpdate(ctx context.Context, arg ListSharedAgentRuntimesForUpdateParams) ([]AgentRuntime, error) { + rows, err := q.db.Query(ctx, listSharedAgentRuntimesForUpdate, arg.WorkspaceID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AgentRuntime{} + for rows.Next() { + var i AgentRuntime + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.DaemonID, + &i.Name, + &i.RuntimeMode, + &i.Provider, + &i.Status, + &i.DeviceInfo, + &i.Metadata, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.LegacyDaemonID, + &i.Visibility, + &i.ProfileID, + &i.CustomName, + &i.SharedWith, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listStaleOfflineRuntimeGCCandidates = `-- name: ListStaleOfflineRuntimeGCCandidates :many SELECT id FROM agent_runtime WHERE status = 'offline' @@ -1179,9 +1245,9 @@ func (q *Queries) ListStaleOfflineRuntimeGCCandidates(ctx context.Context, arg L } const listVisibleAgentRuntimes = `-- name: ListVisibleAgentRuntimes :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE workspace_id = $1 - AND (owner_id = $2 OR visibility = 'public') + AND (owner_id = $2 OR visibility = 'public' OR (visibility = 'private' AND $2 = ANY(shared_with))) ORDER BY created_at ASC ` @@ -1192,7 +1258,7 @@ type ListVisibleAgentRuntimesParams struct { // A private runtime is another member's machine and must not leak into their // runtime list. The owner can always see their own runtime; everyone else -// sees only runtimes the owner has explicitly shared with the workspace. +// sees only runtimes shared with them or with the entire workspace. func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleAgentRuntimesParams) ([]AgentRuntime, error) { rows, err := q.db.Query(ctx, listVisibleAgentRuntimes, arg.WorkspaceID, arg.OwnerID) if err != nil { @@ -1220,6 +1286,7 @@ func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleA &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -1232,7 +1299,7 @@ func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleA } const lockAgentRuntime = `-- name: LockAgentRuntime :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE id = $1 FOR UPDATE ` @@ -1271,12 +1338,13 @@ func (q *Queries) LockAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRu &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } const lockAgentRuntimeForAccessChange = `-- name: LockAgentRuntimeForAccessChange :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name FROM agent_runtime WHERE id = $1 FOR NO KEY UPDATE +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with FROM agent_runtime WHERE id = $1 FOR NO KEY UPDATE ` // Binding and visibility writers share this lock and recheck access under it. @@ -1303,6 +1371,7 @@ func (q *Queries) LockAgentRuntimeForAccessChange(ctx context.Context, id pgtype &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } @@ -1314,6 +1383,7 @@ WHERE EXISTS ( WHERE task.chat_session_id = cs.id AND task.runtime_id = $1 AND agent.owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ) ORDER BY cs.id @@ -1321,15 +1391,16 @@ FOR UPDATE OF cs NOWAIT ` type LockRuntimeRevocationChatSessionsParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } // Revocation already holds affected agents. Never wait in the reverse of the // normal session -> agent/task order: a concurrent chat writer yields a 409 // and the entire revocation rolls back before changing anything. func (q *Queries) LockRuntimeRevocationChatSessions(ctx context.Context, arg LockRuntimeRevocationChatSessionsParams) ([]pgtype.UUID, error) { - rows, err := q.db.Query(ctx, lockRuntimeRevocationChatSessions, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, lockRuntimeRevocationChatSessions, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -1348,6 +1419,60 @@ func (q *Queries) LockRuntimeRevocationChatSessions(ctx context.Context, arg Loc return items, nil } +const lockRuntimeShareMemberRemoval = `-- name: LockRuntimeShareMemberRemoval :one +SELECT id FROM member +WHERE workspace_id = $1 AND user_id = $2 +FOR UPDATE +` + +type LockRuntimeShareMemberRemovalParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UserID pgtype.UUID `json:"user_id"` +} + +// Prevent new grants until membership deletion commits. Grant writers use +// NOWAIT on this row, so they never wait while holding a runtime we need. +func (q *Queries) LockRuntimeShareMemberRemoval(ctx context.Context, arg LockRuntimeShareMemberRemovalParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, lockRuntimeShareMemberRemoval, arg.WorkspaceID, arg.UserID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const lockRuntimeShareMembers = `-- name: LockRuntimeShareMembers :many +SELECT user_id FROM member +WHERE workspace_id = $1 AND user_id = ANY($2::uuid[]) +ORDER BY user_id +FOR KEY SHARE NOWAIT +` + +type LockRuntimeShareMembersParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UserIds []pgtype.UUID `json:"user_ids"` +} + +// Keep grant validation and member removal serialized until commit. NOWAIT +// avoids the member-removal path's reverse member/runtime lock order. +func (q *Queries) LockRuntimeShareMembers(ctx context.Context, arg LockRuntimeShareMembersParams) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, lockRuntimeShareMembers, arg.WorkspaceID, arg.UserIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var user_id pgtype.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const lockRuntimesForMerge = `-- name: LockRuntimesForMerge :many SELECT id, workspace_id, owner_id FROM agent_runtime WHERE id = ANY($1::uuid[]) @@ -1422,7 +1547,7 @@ const markAgentRuntimeOnline = `-- name: MarkAgentRuntimeOnline :one UPDATE agent_runtime SET status = 'online', last_seen_at = now(), updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with ` // Used on the offline→online transition (and on first heartbeat after @@ -1449,6 +1574,7 @@ func (q *Queries) MarkAgentRuntimeOnline(ctx context.Context, id pgtype.UUID) (A &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } @@ -1768,19 +1894,20 @@ UPDATE agent SET runtime_id = NULL, updated_at = now() WHERE runtime_id = $1 AND owner_id IS DISTINCT FROM $2 + AND NOT COALESCE(owner_id = ANY($3::uuid[]), false) RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters ` type UnbindNonOwnerAgentsFromRuntimeParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"` + SharedWith []pgtype.UUID `json:"shared_with"` } -// The companion to CancelNonOwnerTasksByRuntime for a public-to-private -// transition. Only teammates lose the binding; the runtime owner's agents -// remain bound and usable after the machine becomes private again. +// Companion to CancelNonOwnerTasksByRuntime: preserve agents belonging to +// the runtime owner or to recipients who retain access. func (q *Queries) UnbindNonOwnerAgentsFromRuntime(ctx context.Context, arg UnbindNonOwnerAgentsFromRuntimeParams) ([]Agent, error) { - rows, err := q.db.Query(ctx, unbindNonOwnerAgentsFromRuntime, arg.RuntimeID, arg.RuntimeOwnerID) + rows, err := q.db.Query(ctx, unbindNonOwnerAgentsFromRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith) if err != nil { return nil, err } @@ -1922,7 +2049,7 @@ const updateAgentRuntimeCustomName = `-- name: UpdateAgentRuntimeCustomName :one UPDATE agent_runtime SET custom_name = $1, updated_at = now() WHERE id = $2 -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with ` type UpdateAgentRuntimeCustomNameParams struct { @@ -1956,6 +2083,7 @@ func (q *Queries) UpdateAgentRuntimeCustomName(ctx context.Context, arg UpdateAg &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } @@ -1966,7 +2094,7 @@ SET custom_name = $1, updated_at = now() WHERE workspace_id = $2 AND daemon_id = $3 AND ($4::uuid IS NULL OR owner_id = $4) -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with ` type UpdateAgentRuntimeCustomNameByDaemonParams struct { @@ -2014,6 +2142,7 @@ func (q *Queries) UpdateAgentRuntimeCustomNameByDaemon(ctx context.Context, arg &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ); err != nil { return nil, err } @@ -2027,23 +2156,24 @@ func (q *Queries) UpdateAgentRuntimeCustomNameByDaemon(ctx context.Context, arg const updateAgentRuntimeVisibility = `-- name: UpdateAgentRuntimeVisibility :one UPDATE agent_runtime -SET visibility = $1, updated_at = now() -WHERE id = $2 -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name +SET visibility = $1, shared_with = $2::uuid[], updated_at = now() +WHERE id = $3 +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with ` type UpdateAgentRuntimeVisibilityParams struct { - Visibility string `json:"visibility"` - ID pgtype.UUID `json:"id"` + Visibility string `json:"visibility"` + SharedWith []pgtype.UUID `json:"shared_with"` + ID pgtype.UUID `json:"id"` } -// Toggles a runtime between 'private' (only owner can bind agents) and -// 'public' (any workspace member can). Default for new rows is 'private' +// Replaces the runtime's access policy: private admits owner plus shared_with; +// public admits any workspace member. Default for new rows is private // (see migration 083). Gated at the handler layer to the runtime owner only: // visibility is consent to use that person's machine, not a workspace-governance // setting. func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAgentRuntimeVisibilityParams) (AgentRuntime, error) { - row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.ID) + row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.SharedWith, arg.ID) var i AgentRuntime err := row.Scan( &i.ID, @@ -2063,6 +2193,7 @@ func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAg &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, ) return i, err } @@ -2095,7 +2226,7 @@ DO UPDATE SET updated_at = now() WHERE $10::uuid IS NULL OR agent_runtime.owner_id = $10::uuid -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, (xmax = 0) AS inserted +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with, (xmax = 0) AS inserted ` type UpsertAgentRuntimeParams struct { @@ -2129,6 +2260,7 @@ type UpsertAgentRuntimeRow struct { Visibility string `json:"visibility"` ProfileID pgtype.UUID `json:"profile_id"` CustomName pgtype.Text `json:"custom_name"` + SharedWith []pgtype.UUID `json:"shared_with"` Inserted bool `json:"inserted"` } @@ -2179,6 +2311,7 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, &i.Inserted, ) return i, err @@ -2214,7 +2347,7 @@ DO UPDATE SET updated_at = now() WHERE $11::uuid IS NULL OR agent_runtime.owner_id = $11::uuid -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, (xmax = 0) AS inserted +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, custom_name, shared_with, (xmax = 0) AS inserted ` type UpsertAgentRuntimeWithProfileParams struct { @@ -2249,6 +2382,7 @@ type UpsertAgentRuntimeWithProfileRow struct { Visibility string `json:"visibility"` ProfileID pgtype.UUID `json:"profile_id"` CustomName pgtype.Text `json:"custom_name"` + SharedWith []pgtype.UUID `json:"shared_with"` Inserted bool `json:"inserted"` } @@ -2293,6 +2427,7 @@ func (q *Queries) UpsertAgentRuntimeWithProfile(ctx context.Context, arg UpsertA &i.Visibility, &i.ProfileID, &i.CustomName, + &i.SharedWith, &i.Inserted, ) return i, err diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index b9001a75c..4647b4245 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -281,13 +281,13 @@ ORDER BY id FOR UPDATE; -- name: ListNonOwnerAgentsByRuntime :many --- Returns every agent bound to a runtime whose owner differs from its owner, --- including hidden Builder execution carriers. This is the dependency plan shown before a public --- runtime is made private: owner-owned agents keep their binding, while every --- teammate-owned agent loses machine access. +-- Returns bound agents whose owners lose access under the desired shared_with +-- list, including hidden Builder execution carriers. Runtime-owner agents keep +-- their binding. SELECT * FROM agent WHERE runtime_id = @runtime_id AND owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false) ORDER BY name ASC; -- name: ListNonOwnerAgentsByRuntimeForUpdate :many @@ -296,6 +296,7 @@ ORDER BY name ASC; SELECT * FROM agent WHERE runtime_id = @runtime_id AND owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false) ORDER BY id FOR UPDATE NOWAIT; @@ -879,6 +880,7 @@ WHERE id = ( r.owner_id IS NULL OR a.owner_id IS NULL OR r.owner_id = a.owner_id + OR a.owner_id = ANY(r.shared_with) ) ) ) @@ -926,6 +928,7 @@ WHERE id IN ( r.owner_id IS NULL OR a.owner_id IS NULL OR r.owner_id = a.owner_id + OR a.owner_id = ANY(r.shared_with) ) ) ) diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index ccedc13f3..25fb913d0 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -11,10 +11,10 @@ ORDER BY id ASC; -- name: ListVisibleAgentRuntimes :many -- A private runtime is another member's machine and must not leak into their -- runtime list. The owner can always see their own runtime; everyone else --- sees only runtimes the owner has explicitly shared with the workspace. +-- sees only runtimes shared with them or with the entire workspace. SELECT * FROM agent_runtime WHERE workspace_id = $1 - AND (owner_id = $2 OR visibility = 'public') + AND (owner_id = $2 OR visibility = 'public' OR (visibility = 'private' AND $2 = ANY(shared_with))) ORDER BY created_at ASC; -- name: GetAgentRuntime :one @@ -149,13 +149,13 @@ WHERE sqlc.narg('required_owner_id')::uuid IS NULL RETURNING *, (xmax = 0) AS inserted; -- name: UpdateAgentRuntimeVisibility :one --- Toggles a runtime between 'private' (only owner can bind agents) and --- 'public' (any workspace member can). Default for new rows is 'private' +-- Replaces the runtime's access policy: private admits owner plus shared_with; +-- public admits any workspace member. Default for new rows is private -- (see migration 083). Gated at the handler layer to the runtime owner only: -- visibility is consent to use that person's machine, not a workspace-governance -- setting. UPDATE agent_runtime -SET visibility = @visibility, updated_at = now() +SET visibility = @visibility, shared_with = @shared_with::uuid[], updated_at = now() WHERE id = @id RETURNING *; @@ -375,15 +375,14 @@ WHERE (runtime_id = ANY(@runtime_ids::uuid[]) OR agent_id = ANY(@agent_ids::uuid RETURNING *; -- name: ListNonOwnerActiveTasksByRuntime :many --- Lists live tasks targeting a public runtime but belonging to somebody other --- than its owner, including hidden Builder execution carriers. It forms part of the public-to-private --- confirmation plan so the owner explicitly approves cancelling work that --- could otherwise keep running after machine access is revoked. +-- Lists live tasks whose agent owner will lose runtime access, including +-- hidden Builder execution carriers. shared_with is the desired recipient list. SELECT task.* FROM agent_task_queue AS task JOIN agent ON agent.id = task.agent_id WHERE task.runtime_id = @runtime_id AND agent.owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ORDER BY task.id; @@ -395,6 +394,7 @@ FROM agent_task_queue AS task JOIN agent ON agent.id = task.agent_id WHERE task.runtime_id = @runtime_id AND agent.owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ORDER BY task.id FOR UPDATE OF task; @@ -415,6 +415,7 @@ WHERE EXISTS ( WHERE task.chat_session_id = cs.id AND task.runtime_id = @runtime_id AND agent.owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') ) ORDER BY cs.id @@ -437,6 +438,7 @@ FROM agent WHERE task.agent_id = agent.id AND task.runtime_id = @runtime_id AND agent.owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false) AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') RETURNING task.*; @@ -483,13 +485,13 @@ WHERE runtime_id = $1 AND kind = 'user' RETURNING *; -- name: UnbindNonOwnerAgentsFromRuntime :many --- The companion to CancelNonOwnerTasksByRuntime for a public-to-private --- transition. Only teammates lose the binding; the runtime owner's agents --- remain bound and usable after the machine becomes private again. +-- Companion to CancelNonOwnerTasksByRuntime: preserve agents belonging to +-- the runtime owner or to recipients who retain access. UPDATE agent SET runtime_id = NULL, updated_at = now() WHERE runtime_id = @runtime_id AND owner_id IS DISTINCT FROM @runtime_owner_id + AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false) RETURNING *; -- name: DeleteAgentRuntime :exec @@ -663,3 +665,24 @@ SELECT count(*) FROM agent_task_queue WHERE runtime_id = $1; -- refused before any write when any of them belongs to someone else. SELECT id, owner_id FROM agent_runtime WHERE workspace_id = @workspace_id AND daemon_id = @daemon_id; + +-- name: LockRuntimeShareMembers :many +-- Keep grant validation and member removal serialized until commit. NOWAIT +-- avoids the member-removal path's reverse member/runtime lock order. +SELECT user_id FROM member +WHERE workspace_id = @workspace_id AND user_id = ANY(@user_ids::uuid[]) +ORDER BY user_id +FOR KEY SHARE NOWAIT; + +-- name: LockRuntimeShareMemberRemoval :one +-- Prevent new grants until membership deletion commits. Grant writers use +-- NOWAIT on this row, so they never wait while holding a runtime we need. +SELECT id FROM member +WHERE workspace_id = @workspace_id AND user_id = @user_id +FOR UPDATE; + +-- name: ListSharedAgentRuntimesForUpdate :many +SELECT * FROM agent_runtime +WHERE workspace_id = @workspace_id AND @user_id::uuid = ANY(shared_with) +ORDER BY id +FOR NO KEY UPDATE;