Repository navigation
web: tell a Cloud machine whether it is personal (vm-kind owner) or shared #20076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
286676a
509e531
4275a7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { Effect } from "effect"; | ||
| import { coderouterControlRoute } from "@/services/coderouter/requestTelemetry"; | ||
| import { listTeamMemberIdsWithTimeout, vmTeamDirectory, type VmTeamMemberLookup } from "@/services/vms/teamDirectory"; | ||
| import { resolveVmGuestKind } from "@/services/vms/vmKind"; | ||
| import { requireVmPrincipal, vmPrincipalFailureResponse } from "@/services/vms/vmPrincipal"; | ||
|
|
||
| const JSON_HEADERS = { | ||
| "cache-control": "no-store", | ||
| "content-type": "application/json", | ||
| } as const; | ||
|
|
||
| /** | ||
| * Member lists are cached per team for 15 s, so a fleet of machines | ||
| * re-asking every minute costs at most four Stack reads per team and | ||
| * instance a minute, and a new member turns a machine `team` within about | ||
| * 75 s (cache plus the guest's poll). | ||
| * Only successful reads are kept; a failure is asked again next time. | ||
| */ | ||
| const MEMBER_CACHE_TTL_MS = 15_000; | ||
| const MEMBER_CACHE_MAX = 1_000; | ||
| const memberCache = new Map<string, { readonly at: number; readonly lookup: VmTeamMemberLookup }>(); | ||
|
|
||
| async function listMembers(teamId: string): Promise<VmTeamMemberLookup> { | ||
| const now = Date.now(); | ||
| const cached = memberCache.get(teamId); | ||
| if (cached && now - cached.at < MEMBER_CACHE_TTL_MS) return cached.lookup; | ||
| const lookup = await Effect.runPromise(listTeamMemberIdsWithTimeout(vmTeamDirectory(), teamId)); | ||
| if (!("error" in lookup)) { | ||
| if (memberCache.size >= MEMBER_CACHE_MAX) memberCache.clear(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Evict one team instead of clearing the entire cache. When the cache reaches 1,000 entries, the next successful lookup removes every cached team. Subsequent requests must read those teams from Stack again, even within their 60-second TTL. Evict an expired or oldest entry to retain the other successful lookups. 🤖 Prompt for AI Agents |
||
| memberCache.set(teamId, { at: now, lookup }); | ||
| } | ||
| return lookup; | ||
| } | ||
|
|
||
| /** | ||
| * The machine's kind for /etc/cmux/vm-kind (services/vms/vmKind.ts). The | ||
| * guest's root-run cmux-vm-kind-sync asks every minute and writes `owner` or | ||
| * `team`; a 503 leaves the guest's record as it was. | ||
| */ | ||
| export const GET = coderouterControlRoute("vm_reflection_kind", "/api/vm/reflection/kind", async (request) => { | ||
| const auth = await requireVmPrincipal(request); | ||
| if (!auth.ok) return vmPrincipalFailureResponse(auth.reason); | ||
| const vm = auth.principal.vm; | ||
| const kind = await resolveVmGuestKind(vm, { listMembers }); | ||
| if (kind === null) { | ||
| return new Response( | ||
| JSON.stringify({ error: "vm_kind_unavailable", retryable: true }), | ||
| { status: 503, headers: { ...JSON_HEADERS, "retry-after": "30" } }, | ||
|
Comment on lines
+45
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: set -eu
printf '%s\n' '--- route ---'
nl -ba web/app/api/vm/reflection/kind/route.ts
printf '%s\n' '--- related references ---'
rg -n -F --glob '!node_modules' --glob '!dist' --glob '!build' \
-e 'vm-kind' -e 'vm_kind_unavailable' -e '/api/vm/reflection/kind' -e 'reflection.cmux.internal/kind' \
. || test "$?" -eq 1
printf '%s\n' '--- changed paths ---'
git diff --name-status b0edde3a379e29b039b273e23b0b45f520c78d33 509e5310435ed706966f6ac510aae22c628d4420
printf '%s\n' '--- route diff ---'
git diff --unified=80 b0edde3a379e29b039b273e23b0b45f520c78d33 509e5310435ed706966f6ac510aae22c628d4420 -- web/app/api/vm/reflection/kind/route.tsRepository: manaflow-ai/cmux Length of output: 7166 🏁 Script executed: set -eu
printf '%s\n' '--- vm kind resolver ---'
nl -ba web/services/vms/vmKind.ts
printf '%s\n' '--- team lookup declarations ---'
rg -n -F --glob '*.ts' -e 'export type VmTeamMemberLookup' -e 'listTeamMemberIdsWithTimeout' -e 'interface VmTeamMemberLookup' web/services web/app web/tests
printf '%s\n' '--- route tests ---'
nl -ba web/tests/vm-kind-route.test.ts | sed -n '1,190p'Repository: manaflow-ai/cmux Length of output: 12468 Fail closed when a previous After the cached member list expires, if the team has gained another member and the next lookup fails, this route returns Expire or revoke the previous 🤖 Prompt for AI Agents |
||
| ); | ||
| } | ||
| return new Response(JSON.stringify({ vm_id: vm.id, kind }), { status: 200, headers: JSON_HEADERS }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| // The machine kind a cmux Cloud guest records in /etc/cmux/vm-kind. | ||
| // | ||
| // cmux-tui on the machine refuses to start or receive a chat on a Cloud VM | ||
| // unless that root-owned record reads `owner` (cmux-tui agent_start_host.rs, | ||
| // "a remote start is refused on a team VM, and on a Cloud VM whose kind the | ||
| // daemon cannot tell"). The guest's root-run cmux-vm-kind-sync asks | ||
| // GET /api/vm/reflection/kind and writes the answer; nothing the guest claims | ||
| // is an input here. The answer comes from the machine's own row, identified by | ||
| // the platform edge (services/vms/vmPrincipal.ts). | ||
| // | ||
| // - `owner`: exactly one person can reach the machine, its creator. That is a | ||
| // machine on the creator's own account (owner_team_id is the creator's | ||
| // user id, the synthetic personal team), or a machine of a Stack team whose | ||
| // only member is the creator. | ||
| // - `team`: anyone else can, or might: a team with any other member, a team | ||
| // the creator has left, or a team that no longer exists. | ||
| // - unknown (null): the member list could not be read. The route answers a | ||
| // retryable 503 and the guest keeps whatever it wrote last. | ||
| // | ||
| // Team membership changes, so the kind is not fixed at create: the guest | ||
| // re-asks every minute and a machine whose team gains a member turns `team` | ||
| // on its next answer. | ||
| import type { VmTeamMemberLookup } from "./teamDirectory"; | ||
| import type { VmPrincipalRow } from "./vmPrincipalContract"; | ||
|
|
||
| export type VmGuestKind = "owner" | "team"; | ||
|
|
||
| /** The row facts the kind depends on. */ | ||
| export type VmGuestKindRow = Pick<VmPrincipalRow, "userId" | "ownerTeamId">; | ||
|
|
||
| /** True when the machine belongs to its creator's own account, with no Stack team to ask. */ | ||
| export function vmIsOnPersonalAccount(row: VmGuestKindRow): boolean { | ||
| const owner = row.ownerTeamId.trim(); | ||
| return owner !== "" && owner === row.userId.trim(); | ||
| } | ||
|
|
||
| /** | ||
| * The kind for a machine on a Stack team, from that team's member list. Null | ||
| * when the list could not be read (fail closed: never `owner` on an error). | ||
| */ | ||
| export function vmGuestKindFromMembers(row: VmGuestKindRow, lookup: VmTeamMemberLookup): VmGuestKind | null { | ||
| if ("error" in lookup) return null; | ||
| // A missing team is nobody's personal machine. | ||
| if (lookup.memberIds === null) return "team"; | ||
| const creator = row.userId.trim(); | ||
| const members = new Set(lookup.memberIds.map((id) => id.trim())); | ||
| return creator !== "" && members.size === 1 && members.has(creator) ? "owner" : "team"; | ||
| } | ||
|
|
||
| export type VmGuestKindDependencies = { | ||
| /** A bounded member lookup (listTeamMemberIdsWithTimeout over the Stack directory). */ | ||
| readonly listMembers: (teamId: string) => Promise<VmTeamMemberLookup>; | ||
| }; | ||
|
|
||
| /** The machine's kind, asking Stack only for a team-owned machine. */ | ||
| export async function resolveVmGuestKind( | ||
| row: VmGuestKindRow, | ||
| dependencies: VmGuestKindDependencies, | ||
| ): Promise<VmGuestKind | null> { | ||
| if (vmIsOnPersonalAccount(row)) return "owner"; | ||
| const teamId = row.ownerTeamId.trim(); | ||
| if (!teamId) return null; | ||
| return vmGuestKindFromMembers(row, await dependencies.listMembers(teamId)); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; | ||
| import type { VmPrincipalRow } from "../services/vms/vmPrincipalContract"; | ||
| import type { VmTeamMemberLookup } from "../services/vms/teamDirectory"; | ||
|
|
||
| // Move to Cloud on a production personal machine (cx-rppd.14). cmux-tui on a | ||
| // Cloud VM refuses to start or receive a chat unless the root-owned | ||
| // /etc/cmux/vm-kind reads `owner` (cmux-tui agent_start_host.rs). The guest | ||
| // asks GET /api/vm/reflection/kind which kind it is; the answer comes from | ||
| // the machine's own row and its owner team's members, never from the guest. | ||
|
|
||
| // bun's mock.module is process-global; each override delegates unless active. | ||
| let active = false; | ||
| let principal: VmPrincipalRow; | ||
| let members: (teamId: string) => Promise<readonly string[] | null>; | ||
| const memberCalls: string[] = []; | ||
|
|
||
| const realPrincipal = await import("../services/vms/vmPrincipal"); | ||
| const realRequireVmPrincipal = realPrincipal.requireVmPrincipal; | ||
| mock.module("../services/vms/vmPrincipal", () => ({ | ||
| ...realPrincipal, | ||
| requireVmPrincipal: ((request: Request, ...rest: unknown[]) => | ||
| active | ||
| ? Promise.resolve({ ok: true, principal: { vm: principal, userId: principal.userId, teamId: principal.ownerTeamId } }) | ||
| : (realRequireVmPrincipal as (...args: unknown[]) => unknown)(request, ...rest)) as typeof realRequireVmPrincipal, | ||
| })); | ||
| const realDirectory = await import("../services/vms/teamDirectory"); | ||
| const realVmTeamDirectory = realDirectory.vmTeamDirectory; | ||
| mock.module("../services/vms/teamDirectory", () => ({ | ||
| ...realDirectory, | ||
| vmTeamDirectory: () => active | ||
| ? { | ||
| listMemberIds: async (teamId: string) => { | ||
| memberCalls.push(teamId); | ||
| return members(teamId); | ||
| }, | ||
| } | ||
| : realVmTeamDirectory(), | ||
| })); | ||
|
|
||
| const { GET } = await import("../app/api/vm/reflection/kind/route"); | ||
| const { resolveVmGuestKind, vmGuestKindFromMembers, vmIsOnPersonalAccount } = await import("../services/vms/vmKind"); | ||
|
|
||
| let rowSeq = 0; | ||
| function row(overrides: Partial<VmPrincipalRow>): VmPrincipalRow { | ||
| rowSeq += 1; | ||
| return { | ||
| id: `11111111-2222-4333-8444-${String(rowSeq).padStart(12, "0")}`, | ||
| userId: "user-1", | ||
| ownerTeamId: "user-1", | ||
| coderouterPoolId: null, | ||
| billingTeamId: null, | ||
| billingPlanId: null, | ||
| provider: "freestyle", | ||
| providerVmId: "provider-vm-1", | ||
| displayName: null, | ||
| slug: "hardy-pink-jay", | ||
| imageId: "sh-1", | ||
| imageVersion: null, | ||
| status: "running", | ||
| createdAt: new Date("2026-10-12T00:00:00Z"), | ||
| providerMetadata: {}, | ||
| ...overrides, | ||
| } as VmPrincipalRow; | ||
| } | ||
|
|
||
| describe("machine kind (services/vms/vmKind.ts)", () => { | ||
| const lookup = (memberIds: readonly string[] | null): VmTeamMemberLookup => ({ memberIds }); | ||
|
|
||
| test("a machine on the creator's own account is owner, with no team to ask", async () => { | ||
| const asked: string[] = []; | ||
| const vm = row({ userId: "user-1", ownerTeamId: "user-1" }); | ||
| expect(vmIsOnPersonalAccount(vm)).toBe(true); | ||
| expect(await resolveVmGuestKind(vm, { listMembers: async (id) => { asked.push(id); return lookup([]); } })).toBe("owner"); | ||
| expect(asked).toEqual([]); | ||
| }); | ||
|
|
||
| test("a team machine is owner only while the creator is the team's only member", () => { | ||
| const vm = row({ userId: "user-1", ownerTeamId: "team-1" }); | ||
| expect(vmIsOnPersonalAccount(vm)).toBe(false); | ||
| expect(vmGuestKindFromMembers(vm, lookup(["user-1"]))).toBe("owner"); | ||
| expect(vmGuestKindFromMembers(vm, lookup(["user-1", "user-2"]))).toBe("team"); | ||
| // The creator left: someone else's team. | ||
| expect(vmGuestKindFromMembers(vm, lookup(["user-2"]))).toBe("team"); | ||
| expect(vmGuestKindFromMembers(vm, lookup([]))).toBe("team"); | ||
| // The team is gone: nobody's personal machine. | ||
| expect(vmGuestKindFromMembers(vm, lookup(null))).toBe("team"); | ||
| }); | ||
|
|
||
| test("a failed member lookup is unknown, never owner", async () => { | ||
| const vm = row({ userId: "user-1", ownerTeamId: "team-1" }); | ||
| expect(vmGuestKindFromMembers(vm, { error: "timeout" })).toBeNull(); | ||
| expect(vmGuestKindFromMembers(vm, { error: "error" })).toBeNull(); | ||
| expect(await resolveVmGuestKind(row({ ownerTeamId: " " }), { listMembers: async () => lookup(["user-1"]) })).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("GET /api/vm/reflection/kind", () => { | ||
| beforeAll(() => { active = true; }); | ||
| afterAll(() => { active = false; }); | ||
|
|
||
| async function kindFor(vm: VmPrincipalRow): Promise<{ status: number; body: Record<string, unknown> }> { | ||
| principal = vm; | ||
| const response = await GET(new Request("https://reflection.cmux.internal/kind")); | ||
| return { status: response.status, body: (await response.json()) as Record<string, unknown> }; | ||
| } | ||
|
|
||
| test("answers owner for a personal machine and team for a shared one", async () => { | ||
| members = async (teamId) => (teamId === "team-solo" ? ["user-1"] : ["user-1", "user-2"]); | ||
| const personal = row({ ownerTeamId: "user-1" }); | ||
| expect(await kindFor(personal)).toEqual({ status: 200, body: { vm_id: personal.id, kind: "owner" } }); | ||
| const solo = row({ ownerTeamId: "team-solo" }); | ||
| expect(await kindFor(solo)).toEqual({ status: 200, body: { vm_id: solo.id, kind: "owner" } }); | ||
| const shared = row({ ownerTeamId: "team-shared" }); | ||
| expect(await kindFor(shared)).toEqual({ status: 200, body: { vm_id: shared.id, kind: "team" } }); | ||
| }); | ||
|
|
||
| test("reuses a team's member list briefly, and never caches a failure", async () => { | ||
| memberCalls.length = 0; | ||
| members = async () => ["user-1"]; | ||
| await kindFor(row({ ownerTeamId: "team-cached" })); | ||
| await kindFor(row({ ownerTeamId: "team-cached" })); | ||
| expect(memberCalls).toEqual(["team-cached"]); | ||
| let fail = true; | ||
| members = async () => { | ||
| if (fail) throw new Error("stack down"); | ||
| return ["user-1"]; | ||
| }; | ||
| expect((await kindFor(row({ ownerTeamId: "team-flaky" }))).status).toBe(503); | ||
| fail = false; | ||
| expect((await kindFor(row({ ownerTeamId: "team-flaky" }))).body.kind).toBe("owner"); | ||
| expect(memberCalls).toEqual(["team-cached", "team-flaky", "team-flaky"]); | ||
| }); | ||
|
|
||
| test("a failed member lookup is a retryable 503 with no kind", async () => { | ||
| members = async () => { throw new Error("stack down"); }; | ||
| const result = await kindFor(row({ ownerTeamId: "team-unreachable" })); | ||
| expect(result.status).toBe(503); | ||
| expect(result.body).toEqual({ error: "vm_kind_unavailable", retryable: true }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Share an in-flight lookup for each team.
When several machines request an uncached team at the same time, each request passes the cache check before any lookup completes. Each request then calls Stack. This defeats the stated one-read-per-team goal and can amplify a fleet-wide refresh into many concurrent reads. Store an in-flight promise per team, and remove it when the lookup settles. Cache only successful results. Based on learnings, a check-then-await-then-set cache permits duplicate async work.
🤖 Prompt for AI Agents
Source: Learnings