Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions web/app/api/vm/[id]/attach-endpoint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ export async function POST(
// An opted-in machine's agent-update exec runs after the response.
deferAfterResponse: (work) => runAfterResponse(() => Effect.runPromise(work)),
modelPlane: vmModelPlaneRevoker(),
// vmKind: owner for the creator's machine while they remain a team member.
// vmKind: owner for the creator's machine while they remain a team member,
// and for a teammate it was shared with (services/vms/chatGrants.ts).
teamDirectory: vmTeamDirectory(),
readChatGrants: true,
}), { request, deadline: { operation: "attach", continuationKey: `attach:${id}` } });
if (!run.ok) return run.response;
return jsonResponse(run.value);
Expand All @@ -107,8 +109,10 @@ export async function POST(
sessionTitle,
options: { requireDaemon, sessionId, attachmentId },
modelPlane: vmModelPlaneRevoker(),
// vmKind: owner for the creator's machine while they remain a team member.
// vmKind: owner for the creator's machine while they remain a team member,
// and for a teammate it was shared with (services/vms/chatGrants.ts).
teamDirectory: vmTeamDirectory(),
readChatGrants: true,
}), { request, deadline: { operation: "attach", continuationKey: `attach:${id}` } });
if (!run.ok) return run.response;
const endpoint = run.value;
Expand Down
66 changes: 66 additions & 0 deletions web/app/api/vm/[id]/chat-grants/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
jsonResponse,
resolveVmRouteAccountScope,
withAuthedVmApiRoute,
type AuthedVmRouteContext,
} from "../../../../../../services/vms/routeHelpers";
import { setSpanAttributes } from "../../../../../../services/telemetry";
import { runVmRoute } from "../../../../../../services/vms/routeWorkflow";
import { grantVmChat, revokeVmChat, vmTeamAdminCheck } from "../../../../../../services/vms/chatGrants";
import { vmTeamDirectory } from "../../../../../../services/vms/teamDirectory";

type Params = { params: Promise<{ id: string; userId: string }> };

// Share app-started chats on a team Cloud machine with one teammate (PUT), or
// take the share back (DELETE). The machine's creator or a team admin only;
// the teammate must be on the machine's team (services/vms/chatGrants.ts).
export async function PUT(request: Request, { params }: Params): Promise<Response> {
return withAuthedVmApiRoute(
request,
"/api/vm/[id]/chat-grants/[userId]",
{ "cmux.vm.operation": "grant_chat" },
"/api/vm/[id]/chat-grants/[userId] failed",
(context) => share(request, params, context, "grant"),
);
}

export async function DELETE(request: Request, { params }: Params): Promise<Response> {
return withAuthedVmApiRoute(
request,
"/api/vm/[id]/chat-grants/[userId]",
{ "cmux.vm.operation": "revoke_chat_grant" },
"/api/vm/[id]/chat-grants/[userId] failed",
(context) => share(request, params, context, "revoke"),
);
}

async function share(
request: Request,
params: Params["params"],
{ user, span }: AuthedVmRouteContext,
action: "grant" | "revoke",
): Promise<Response> {
const { id, userId } = await params;
const account = resolveVmRouteAccountScope(user, request);
if (!account.ok) return account.response;
setSpanAttributes(span, { "cmux.vm.id": id });
const input = {
userId: user.id,
billingTeamId: account.entitlements.billingTeamId,
teamIds: user.teamIds,
providerVmId: id,
targetUserId: userId,
teamDirectory: vmTeamDirectory(),
isTeamAdmin: vmTeamAdminCheck(),
};
if (action === "grant") {
const run = await runVmRoute(grantVmChat(input), { request });
if (!run.ok) return run.response;
if (!run.value.ok) return jsonResponse(run.value.refusal, run.value.refusal.status);
return jsonResponse({ grant: run.value.value });
}
const run = await runVmRoute(revokeVmChat(input), { request });
if (!run.ok) return run.response;
if (!run.value.ok) return jsonResponse(run.value.refusal, run.value.refusal.status);
return jsonResponse(run.value.value);
}
38 changes: 38 additions & 0 deletions web/app/api/vm/[id]/chat-grants/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
jsonResponse,
resolveVmRouteAccountScope,
withAuthedVmApiRoute,
} from "../../../../../services/vms/routeHelpers";
import { setSpanAttributes } from "../../../../../services/telemetry";
import { runVmRoute } from "../../../../../services/vms/routeWorkflow";
import { listVmChatGrants } from "../../../../../services/vms/chatGrants";

// Who, besides its creator, may start app chats on a team Cloud machine
// (services/vms/chatGrants.ts). Anyone who can reach the machine may read it.
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
return withAuthedVmApiRoute(
request,
"/api/vm/[id]/chat-grants",
{ "cmux.vm.operation": "list_chat_grants" },
"/api/vm/[id]/chat-grants failed",
async ({ user, span }) => {
const { id } = await params;
const account = resolveVmRouteAccountScope(user, request);
if (!account.ok) return account.response;
setSpanAttributes(span, { "cmux.vm.id": id });
const run = await runVmRoute(listVmChatGrants({
userId: user.id,
billingTeamId: account.entitlements.billingTeamId,
teamIds: user.teamIds,
providerVmId: id,
}), { request });
if (!run.ok) return run.response;
const outcome = run.value;
if (!outcome.ok) return jsonResponse(outcome.refusal, outcome.refusal.status);
return jsonResponse({ grants: outcome.value });
},
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Chat grants on team Cloud machines (Leo, 2026-10-31: "we can't simply block
-- always"). A row lets one teammate start app chats on one machine: the
-- attach endpoint answers `vmKind: owner` to a granted caller who is still on
-- the machine's team (services/vms/vmKind.ts). Additive: only the new
-- /api/vm/[id]/chat-grants routes write it and only the attach endpoint reads
-- it, so app and web code from before this migration run unchanged, and a
-- failed read answers `team` as before. A grant goes with its machine.
-- Replay-safe: each object is created only when no relation of its name exists.
DO $$
BEGIN
IF to_regclass('public.cloud_vm_chat_grants') IS NULL THEN
CREATE TABLE "public"."cloud_vm_chat_grants" (
"vm_id" uuid NOT NULL,
"user_id" text NOT NULL,
"granted_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "cloud_vm_chat_grants_vm_id_cloud_vms_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "public"."cloud_vms"("id") ON DELETE cascade ON UPDATE no action
);
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('public.cloud_vm_chat_grants_vm_user_unique') IS NULL THEN
CREATE UNIQUE INDEX "cloud_vm_chat_grants_vm_user_unique" ON "public"."cloud_vm_chat_grants" USING btree ("vm_id","user_id");
END IF;
END $$;
23 changes: 23 additions & 0 deletions web/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,29 @@ export const cloudVmSessions = pgTable(
],
);

/**
* Teammates who may start app chats on a team Cloud machine besides its
* creator (Leo, 2026-10-31: "we can't simply block always"). The machine's
* creator or a team admin writes a row through /api/vm/[id]/chat-grants; the
* attach endpoint answers `vmKind: owner` to a granted caller still on the
* machine's team (services/vms/vmKind.ts). A grant goes with its machine.
*/
export const cloudVmChatGrants = pgTable(
"cloud_vm_chat_grants",
{
vmId: uuid("vm_id")
.notNull()
.references(() => cloudVms.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
/** Who shared it: the machine's creator or a team admin. */
grantedBy: text("granted_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("cloud_vm_chat_grants_vm_user_unique").on(table.vmId, table.userId),
],
);

// Billing runtime records are transactional lifecycle state, not analytics.
export const cloudVmRuntimeIntervals = pgTable("cloud_vm_runtime_intervals", {
id: uuid("id").defaultRandom().primaryKey(),
Expand Down
169 changes: 169 additions & 0 deletions web/services/vms/chatGrants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Sharing app-started chats on a team Cloud machine (Leo, 2026-10-31: "we
// can't simply block always. we need to be able to get shit running asap").
//
// cmux-tui on a Cloud machine starts an app chat only for an `owner` caller
// (attach-endpoint's `vmKind`, vmKindForCaller). A machine's creator is
// `owner` while they stay on its team; a grant here makes one more teammate
// `owner` for that machine, until it is taken back. The rules mirror
// cmux-platform's machineAccess (src/server/machines/Access.ts):
//
// - start_chat: the creator, or a current member of the machine's team who
// holds a grant. Never on a personal-account machine (only its creator can
// reach it).
// - share_chat (grant and revoke): the creator while still a member, or a
// team admin.
//
// Every Stack read is bounded and fails closed: an unread member list or
// admin bit refuses the change (503) and never widens access.
import * as Effect from "effect/Effect";
import { getStackServerApp } from "../../app/lib/stack";
import { resolveTeamBillingAccess } from "../billing/teamBillingAccess";
import { VmRepository, type CloudVmChatGrantRow, type CloudVmRow } from "./repository";
import { listTeamMemberIdsWithTimeout, type VmTeamDirectory } from "./teamDirectory";
import { vmAcceptsChatGrants } from "./vmKind";
import { requireUserVm } from "./workflows";

/** How long one Stack read (members, or the caller's admin bit) may take. */
const STACK_READ_TIMEOUT_MS = 3000;
/** Stack user ids are short; anything longer is not one. */
const MAX_USER_ID_LENGTH = 200;

export type VmChatGrantRefusal = {
readonly status: 400 | 403 | 409 | 503;
readonly error: string;
readonly message: string;
};

export type VmChatGrantOutcome<A> =
| { readonly ok: true; readonly value: A }
| { readonly ok: false; readonly refusal: VmChatGrantRefusal };

/** Whether `userId` holds Stack `team_admin` on `teamId`. Throws when it cannot tell. */
export type VmTeamAdminCheck = (teamId: string, userId: string) => Promise<boolean>;

export type VmChatGrantAccessInput = {
readonly userId: string;
readonly billingTeamId?: string | null;
readonly teamIds?: readonly string[];
readonly providerVmId: string;
};

export type VmChatGrantShareInput = VmChatGrantAccessInput & {
readonly teamDirectory: VmTeamDirectory;
readonly isTeamAdmin: VmTeamAdminCheck;
/** The teammate the grant is for. */
readonly targetUserId: string;
};

export type VmChatGrantView = {
readonly userId: string;
readonly grantedBy: string;
readonly createdAt: string;
};

export function vmChatGrantView(row: CloudVmChatGrantRow): VmChatGrantView {
return { userId: row.userId, grantedBy: row.grantedBy, createdAt: row.createdAt.toISOString() };
}

const refuse = (status: VmChatGrantRefusal["status"], error: string, message: string) =>
({ ok: false as const, refusal: { status, error, message } });

const unavailable = () => refuse(503, "vm_chat_grants_unavailable", "Machine sharing is unavailable right now. Try again in a moment.");
const forbidden = () => refuse(403, "vm_chat_share_forbidden", "Only the machine's creator or a team admin can share chats on it.");
const personalMachine = () => refuse(409, "vm_chat_grants_personal_machine", "This machine is on a personal account, so only its creator can start chats on it.");

/** Who may start chats on the machine besides its creator. Anyone who can reach the machine may read it. */
export function listVmChatGrants(input: VmChatGrantAccessInput) {
return Effect.gen(function* () {
const repo = yield* VmRepository;
const vm = yield* requireUserVm(input);
if (!vmAcceptsChatGrants(vm)) return { ok: true as const, value: [] as VmChatGrantView[] };
if (!repo.listVmChatGrants) return unavailable();
const rows = yield* repo.listVmChatGrants(vm.id);
return { ok: true as const, value: rows.map(vmChatGrantView) };
});
}

/** Share chat starts on the machine with a current teammate. Idempotent: an existing grant is kept. */
export function grantVmChat(input: VmChatGrantShareInput) {
return Effect.gen(function* () {
const repo = yield* VmRepository;
const vm = yield* requireUserVm(input);
const target = input.targetUserId;
if (target === "" || target.length > MAX_USER_ID_LENGTH || target !== target.trim()) {
return refuse(400, "invalid_request", "Choose a teammate to share this machine with.");
}
if (!vmAcceptsChatGrants(vm)) return personalMachine();
if (target === vm.userId) {
return refuse(400, "vm_chat_grant_creator", "The machine's creator can always start chats on it.");
}
const access = yield* shareChatAccess(vm, input);
if (!access.ok) return access;
if (!access.value.memberIds.includes(target)) {
return refuse(409, "vm_chat_grant_not_member", "Only a current member of this machine's team can be given chats on it.");
}
if (!repo.putVmChatGrant) return unavailable();
const row = yield* repo.putVmChatGrant({ vmId: vm.id, userId: target, grantedBy: input.userId });
return { ok: true as const, value: vmChatGrantView(row) };
});
}

/** Take a share back. Idempotent: `revoked` is false when there was none. */
export function revokeVmChat(input: VmChatGrantShareInput) {
return Effect.gen(function* () {
const repo = yield* VmRepository;
const vm = yield* requireUserVm(input);
const target = input.targetUserId;
if (target === "" || target.length > MAX_USER_ID_LENGTH) {
return refuse(400, "invalid_request", "Choose the teammate whose share to take back.");
}
// A personal-account machine never holds a grant.
if (!vmAcceptsChatGrants(vm)) return { ok: true as const, value: { revoked: false } };
const access = yield* shareChatAccess(vm, input);
if (!access.ok) return access;
if (!repo.deleteVmChatGrant) return unavailable();
const revoked = yield* repo.deleteVmChatGrant({ vmId: vm.id, userId: target });
return { ok: true as const, value: { revoked } };
});
}

/**
* share_chat: the creator who is still on the machine's team, or a team admin.
* Answers the team's member list, which the grant's target is checked against.
*/
function shareChatAccess(
vm: CloudVmRow,
input: VmChatGrantShareInput,
): Effect.Effect<VmChatGrantOutcome<{ readonly memberIds: readonly string[] }>, never> {
return Effect.gen(function* () {
const lookup = yield* listTeamMemberIdsWithTimeout(input.teamDirectory, vm.ownerTeamId, STACK_READ_TIMEOUT_MS);
if ("error" in lookup) return unavailable();
const memberIds = lookup.memberIds;
// A team that no longer exists, or a caller no longer on it, shares nothing.
if (memberIds === null || !memberIds.includes(input.userId)) return forbidden();
if (vm.userId === input.userId) return { ok: true as const, value: { memberIds } };
const admin = yield* Effect.tryPromise({
try: () => input.isTeamAdmin(vm.ownerTeamId, input.userId),
catch: (cause) => cause,
}).pipe(
Effect.timeout(STACK_READ_TIMEOUT_MS),
Effect.map((isAdmin) => isAdmin === true ? "admin" as const : "member" as const),
Effect.catchAll(() => Effect.succeed("unknown" as const)),
);
if (admin === "unknown") return unavailable();
return admin === "admin" ? { ok: true as const, value: { memberIds } } : forbidden();
});
}


/** Stack's `team_admin` on the machine's team, through the billing admin check. Throws when Stack cannot answer. */
export function vmTeamAdminCheck(): VmTeamAdminCheck {
return async (teamId, userId) => {
const user = await getStackServerApp().getUser(userId);
if (!user) return false;
const access = await resolveTeamBillingAccess(user, teamId, { requireAdmin: true });
if (access.ok) return true;
if (access.error === "authorization_unavailable") throw new Error("team admin check unavailable");
return false;
};
}
Loading
Loading