MUL-8185: Support sharing runtimes with specific members (#9455)

* feat: share runtimes with selected workspace members

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

* 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 <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Bohan Jiang
2026-10-23 09:28:22 +08:00
committed by GitHub
co-authored by multica-agent
parent f9036be46a
commit ff674c4e1d
39 changed files with 1525 additions and 498 deletions
+4 -2
View File
@@ -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.
@@ -109,13 +109,15 @@ multica daemon start
达到上限后,新运行会继续排队。你可以在智能体设置中调整单个智能体的并发数,也可以通过 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 调整整台电脑的上限。并行执行会同时占用机器性能、工具账号额度和同一工作目录。
## 私有与公开
## 运行时共享
本地运行时默认是**私有**的:只有运行时所有者可以在它上面创建智能体。工作区的 owner、admin 也不例外——运行时是别人的电脑,在上面跑智能体消耗的是对方的机器和工具凭据。
只有运行时所有者可以把它改为**公开**——管理员可以重命名或删除运行时,但要不要共享由所有者决定。公开后,工作区中的其他成员也可以选择这条运行时;这不会分享底层 AI 编程工具的登录凭据,只是允许成员把智能体的执行路由到这台电脑。
所有者把公开运行时重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者自己的智能体和运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。
如需仅向部分成员开放,在运行时详情中选择**指定成员**,勾选一位或多位工作区成员,然后**保存**。所选成员可以选择该运行时并创建智能体。只有所有者能修改共享名单;获得共享权限不会同时获得重命名、删除或管理运行时的权限。
所有者移除共享成员、将公开范围改为指定成员,或重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者和仍有权限成员的智能体与运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。
取消会保留聊天历史,并在服务端保存草稿和附件恢复数据。网页端和桌面端沿用现有的草稿恢复入口;移动端暂未提供该入口。已开始但尚无输出的运行,会等待守护进程确认取消或服务端收尾宽限期结束,再决定是否恢复输入。已连接的守护进程通过常规检查接收取消。电脑离线时,服务端仍会立即撤销访问,但本地进程可能继续执行,直到重新连接并收到取消;服务端无法保证立即远程停止。
+17
View File
@@ -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<string> {
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<string> {
if (!this.workspaceId) throw new Error("Cannot add a member before a workspace is selected");
+60
View File
@@ -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();
}
});
+9 -3
View File
@@ -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<unknown>(`/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<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
const raw = await this.fetch<unknown>(`/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<void> {
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,
}),
});
}
+10
View File
@@ -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),
+4 -3
View File
@@ -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));
}
+5 -1
View File
@@ -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;
+18
View File
@@ -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);
});
});
+42
View File
@@ -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: "",
};
+4 -2
View File
@@ -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
+22 -7
View File
@@ -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": {
+22 -7
View File
@@ -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": {
+21 -7
View File
@@ -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": {
+21 -7
View File
@@ -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": {
+21 -7
View File
@@ -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": {
@@ -46,21 +46,21 @@ describe("runtime access revocation confirmation", () => {
};
render(<I18nProvider locale="en" resources={{ en: { runtimes: enRuntimes } }}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
<RevokeRuntimeAccessDialog open onOpenChange={close} runtime={runtime} wsId="ws-1" initialPlan={{ agents: body.nonowner_agents, activeTaskIds: [taskId] }} />
<RevokeRuntimeAccessDialog open onOpenChange={close} runtime={runtime} wsId="ws-1" initialPlan={{ agents: body.nonowner_agents, activeTaskIds: [taskId] }} sharedWith={["remaining-member"]} />
</QueryClientProvider>
</I18nProvider>);
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"]);
});
});
@@ -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: () => {
@@ -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 (
<Tooltip>
<TooltipTrigger render={
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2 py-1.5 text-caption">
<Icon className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{t(($) => $.detail.visibility_label[scope])}</span>
</span>
} />
<TooltipContent>{t(($) => $.detail.visibility_hint_readonly[scope])}</TooltipContent>
</Tooltip>
);
}
/**
* 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<RuntimeAccessRevocationPlan | null>(null);
const [revokeOpen, setRevokeOpen] = useState(false);
const [revokeMembers, setRevokeMembers] = useState<string[]>([]);
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 (
<div className="space-y-2">
<div
role="group"
aria-label={t(($) => $.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) => (
<ScopeButton
key={scope}
scope={scope}
active={current === scope}
disabled={disabled}
onClick={() => {
if (current !== scope) apply(scope, []);
}}
/>
))}
<Popover open={pickerOpen} onOpenChange={(open) => setPickerOpen(open && !disabled)}>
<PopoverTrigger
render={<ScopeButton scope="members" active={current === "members"} disabled={disabled} />}
/>
<PopoverContent align="end" className="w-72 p-0">
{pickerOpen ? (
<MemberPicker
members={members.filter((member) => member.user_id !== runtime.owner_id)}
initial={persisted}
saving={updateRuntime.isPending}
onCancel={() => setPickerOpen(false)}
onSave={(ids) => apply("members", ids, () => setPickerOpen(false))}
/>
) : null}
</PopoverContent>
</Popover>
</div>
{current === "members" ? (
<button
type="button"
disabled={disabled}
onClick={() => setPickerOpen(true)}
className="group flex w-full items-center gap-2 rounded-md px-1 py-1 text-left text-caption text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none"
>
<span className="flex shrink-0 -space-x-1.5">
{recipients.slice(0, 4).map((member) => (
<ActorAvatar key={member.user_id} actorType="member" actorId={member.user_id} size="xs" />
))}
</span>
<span className="min-w-0 truncate">
{recipients.length > 0
? recipients.map((member) => member.name).join(", ")
: t(($) => $.detail.visibility_members_count, { count: persisted.length })}
</span>
<span className="ml-auto shrink-0 font-medium text-foreground">
{t(($) => $.detail.visibility_members_edit)}
</span>
</button>
) : null}
<RevokeRuntimeAccessDialog open={revokeOpen} onOpenChange={setRevokeOpen}
runtime={runtime} wsId={wsId} initialPlan={revokePlan} sharedWith={revokeMembers} />
</div>
);
}
function ScopeButton({
scope,
active,
disabled,
...props
}: {
scope: RuntimeAccessScope;
active: boolean;
disabled: boolean;
} & ComponentProps<"button">) {
const { t } = useT("runtimes");
const Icon = SCOPE_ICON[scope];
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
{...props}
aria-pressed={active}
disabled={disabled}
className={`inline-flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-xs px-2 py-1 text-caption font-medium transition-colors ${
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
<Icon className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate">{t(($) => $.detail.visibility_choice[scope])}</span>
</button>
}
/>
<TooltipContent>{t(($) => $.detail.visibility_hint[scope])}</TooltipContent>
</Tooltip>
);
}
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 (
<div className="flex flex-col">
<div className="space-y-2 border-b px-3 py-2.5">
<p className="text-caption font-medium">{t(($) => $.detail.visibility_members_title)}</p>
{members.length >= MEMBER_SEARCH_THRESHOLD ? (
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t(($) => $.detail.visibility_members_search)}
aria-label={t(($) => $.detail.visibility_members_search)}
className="h-8"
autoFocus
/>
) : null}
</div>
<div className="max-h-64 overflow-y-auto overscroll-contain py-1">
{members.length === 0 || visible.length === 0 ? (
<p className="px-3 py-3 text-caption text-muted-foreground">
{members.length === 0
? t(($) => $.detail.visibility_members_none)
: t(($) => $.detail.visibility_members_no_match)}
</p>
) : visible.map((member) => {
const id = `runtime-share-${member.user_id}`;
return (
<div key={member.user_id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-surface-hover">
<Checkbox
id={id}
checked={selected.includes(member.user_id)}
disabled={saving}
onCheckedChange={(checked) => setSelected((ids) => checked === true
? [...ids, member.user_id]
: ids.filter((value) => value !== member.user_id))}
/>
<label htmlFor={id} className="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
<ActorAvatar actorType="member" actorId={member.user_id} size="sm" />
<span className="truncate text-caption">{member.name}</span>
</label>
</div>
);
})}
</div>
<div className="flex items-center justify-end gap-2 border-t px-3 py-2">
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={saving}>
{tc(($) => $.cancel)}
</Button>
<Button
type="button"
size="sm"
onClick={() => onSave(selected)}
disabled={saving || !dirty || selected.length === 0}
aria-busy={saving}
>
{tc(($) => $.save)}
</Button>
</div>
</div>
);
}
@@ -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();
@@ -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)}
</div>
{canEditVisibility ? (
<VisibilityEditor runtime={runtime} />
<RuntimeAccessEditor runtime={runtime} />
) : (
<VisibilityReadout runtime={runtime} />
<RuntimeAccessReadout runtime={runtime} />
)}
</div>
{canDelete && (
@@ -536,144 +530,3 @@ function DiagnosticsCard({
</div>
);
}
// 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 (
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2 py-1.5 text-caption">
<Icon className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">
{t(($) => $.detail.visibility_label[visibility])}
</span>
</span>
}
/>
<TooltipContent>
{t(($) => $.detail.visibility_hint_readonly[visibility])}
</TooltipContent>
</Tooltip>
);
}
// 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<RuntimeAccessRevocationPlan | null>(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 (
<>
<div className="inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<VisibilityChoice
active={current === "private"}
icon={<Lock className="h-3 w-3" />}
label={t(($) => $.detail.visibility_label.private)}
tooltip={t(($) => $.detail.visibility_hint.private)}
disabled={updateRuntime.isPending}
onClick={() => flip("private")}
/>
<VisibilityChoice
active={current === "public"}
icon={<Globe className="h-3 w-3" />}
label={t(($) => $.detail.visibility_label.public)}
tooltip={t(($) => $.detail.visibility_hint.public)}
disabled={updateRuntime.isPending}
onClick={() => flip("public")}
/>
</div>
<RevokeRuntimeAccessDialog
open={revokeOpen}
onOpenChange={setRevokeOpen}
runtime={runtime}
wsId={wsId}
initialPlan={revokePlan}
/>
</>
);
}
function VisibilityChoice({
active,
icon,
label,
tooltip,
disabled,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
tooltip: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-1.5 rounded-xs px-2 py-1 text-caption font-medium transition-colors ${
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
>
<span className="shrink-0">{icon}</span>
<span>{label}</span>
</button>
}
/>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -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 (
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-xs bg-info/10 px-1 text-micro font-medium text-info">
<Globe className="h-2.5 w-2.5" />
{t(($) => $.detail.visibility_label.public)}
<Icon className="h-2.5 w-2.5" />
{t(($) => $.detail.visibility_label[scope])}
</span>
}
/>
<TooltipContent>
{t(($) => $.detail.visibility_hint.public)}
{t(($) => $.detail.visibility_hint_readonly[scope])}
</TooltipContent>
</Tooltip>
);
+12 -8
View File
@@ -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}
+2 -2
View File
@@ -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" {
+4 -4
View File
@@ -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."
+121 -190
View File
@@ -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),
})
}
+148
View File
@@ -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
}
@@ -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": "[email protected]"})
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")
}
}
+49 -2
View File
@@ -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",
})
+3 -3
View File
@@ -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,
)
}
+15
View File
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1 @@
ALTER TABLE agent_runtime DROP COLUMN IF EXISTS shared_with;
@@ -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 '{}';
+15 -10
View File
@@ -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)
)
)
)
+1
View File
@@ -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 {
+182 -47
View File
@@ -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
+7 -4
View File
@@ -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)
)
)
)
+35 -12
View File
@@ -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;