From 073276995b6cf5689e79ab0210933b9f58858553 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:44:21 -0700 Subject: [PATCH 1/3] Revoke a removed member's team VM access on the Stack webhook Stack team_membership.deleted now detaches every tunnel of that user from the team network and drops their identity snapshot, so open terminals and browsers lose the route at once instead of after the 10 minute cron. Tunnel enrollment reconciles against the complete, fresh team list and never detaches when that list is incomplete, so a Mac keeps every team network it belongs to. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/app/api/vm/tunnel/route.ts | 30 ++- web/app/api/webhooks/stack/route.ts | 23 ++ web/app/env.ts | 5 + web/services/auth/README.md | 10 +- web/services/auth/identitySnapshot.ts | 13 +- web/services/auth/stackWebhook.ts | 154 ++++++++++++ web/services/vms/auth.ts | 51 +++- web/services/vms/privateNetwork.ts | 30 ++- web/services/vms/teamMemberRevocation.ts | 61 +++++ web/services/vms/teamNetworkAccess.ts | 111 +++++++++ web/services/vms/workflows.ts | 22 +- .../stack-webhook-team-revocation.test.ts | 234 ++++++++++++++++++ web/tests/vm-private-network.test.ts | 29 ++- web/tests/vm-route-auth.test.ts | 55 ++++ 14 files changed, 793 insertions(+), 35 deletions(-) create mode 100644 web/app/api/webhooks/stack/route.ts create mode 100644 web/services/auth/stackWebhook.ts create mode 100644 web/services/vms/teamMemberRevocation.ts create mode 100644 web/services/vms/teamNetworkAccess.ts create mode 100644 web/tests/stack-webhook-team-revocation.test.ts diff --git a/web/app/api/vm/tunnel/route.ts b/web/app/api/vm/tunnel/route.ts index 0cf0c578df4a..f8994b977d89 100644 --- a/web/app/api/vm/tunnel/route.ts +++ b/web/app/api/vm/tunnel/route.ts @@ -6,6 +6,7 @@ import { withAuthedVmApiRoute, } from "../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../services/telemetry"; +import { verifyCompleteTeamMembership } from "../../../../services/vms/auth"; import { enrollVmTunnel, isWireGuardPublicKey, @@ -79,6 +80,8 @@ export async function POST(request: Request): Promise { const login = stackSession(request); if (!login) return missingStackSession(); + const membership = await tunnelTeamMembership(request, user); + setSpanAttributes(span, { "cmux.vm.tunnel.team_list_complete": membership.teamIdsComplete }); const enrolled = await runVmRoute(enrollVmTunnel({ userId: user.id, provider: provider.id, @@ -95,7 +98,7 @@ export async function POST(request: Request): Promise { stackSessionId: login.id, sessionIssuedAt: login.issuedAt, clientPublicKey, - teamIds: user.teamIds, + ...membership, }), { request }); if (!enrolled.ok) return enrolled.response; const tunnel = enrolled.value; @@ -147,16 +150,18 @@ export async function GET(request: Request): Promise { const provider = providerFromRequest(request, {}); if (!provider.ok) return provider.response; + const membership = await tunnelTeamMembership(request, user); setSpanAttributes(span, { "cmux.vm.provider": provider.id, "cmux.vm.tunnel.device": deviceFingerprint, + "cmux.vm.tunnel.team_list_complete": membership.teamIdsComplete, }); const tunnel = await runVmRoute(readVmTunnel({ userId: user.id, provider: provider.id, deviceFingerprint, tunnelPurpose: parseTunnelPurpose(url.searchParams.get("tunnelPurpose")) ?? "browser", - teamIds: user.teamIds, + ...membership, }), { request }); if (!tunnel.ok) return tunnel.response; return jsonResponse(tunnelPayload(tunnel.value)); @@ -204,6 +209,27 @@ export async function DELETE(request: Request): Promise { ); } +/** + * The teams whose networks this computer's tunnel should be on. + * + * One user can hold several teams' machines at once, so the tunnel joins every + * team network, not only the selected team's. The route's own verification + * resolves only the selected team (`X-Cmux-Team-Id`), so enrollment re-lists + * the complete membership from Stack. Enrollment is rare, so the extra Stack + * call is cheap. When that listing fails, the tunnel still joins the teams the + * route did verify, and nothing is detached, because the missing teams might + * be ones the caller still belongs to. + */ +async function tunnelTeamMembership( + request: Request, + user: { readonly id: string; readonly teamIds: readonly string[] }, +): Promise<{ readonly teamIds: readonly string[]; readonly teamIdsComplete: boolean }> { + const complete = await verifyCompleteTeamMembership(request, user.id); + return complete + ? { teamIds: complete, teamIdsComplete: true } + : { teamIds: user.teamIds, teamIdsComplete: false }; +} + type ProviderResult = | { readonly ok: true; readonly id: ReturnType } | { readonly ok: false; readonly response: Response }; diff --git a/web/app/api/webhooks/stack/route.ts b/web/app/api/webhooks/stack/route.ts new file mode 100644 index 000000000000..8d6c9a94ef64 --- /dev/null +++ b/web/app/api/webhooks/stack/route.ts @@ -0,0 +1,23 @@ +import { env } from "../../../env"; +import { handleStackWebhook } from "../../../../services/auth/stackWebhook"; +import { withApiRouteSpan } from "../../../../services/telemetry"; +import { revokeTeamAccess, revokeTeamMemberAccess } from "../../../../services/vms/teamMemberRevocation"; + +/** + * Stack Auth webhook receiver (delivered by Svix). Authenticated only by the + * Svix signature over the raw body with `STACK_WEBHOOK_SECRET`; no cookie or + * bearer is read. Removing a member revokes their access to that team's Cloud + * machines at once. See services/auth/stackWebhook.ts for the status contract. + */ +export async function POST(request: Request): Promise { + return withApiRouteSpan( + request, + "/api/webhooks/stack", + { "cmux.subsystem": "auth", "cmux.auth.operation": "stack_webhook" }, + () => handleStackWebhook(request, { + webhookSecret: () => env.STACK_WEBHOOK_SECRET, + revokeTeamMemberAccess, + revokeTeamAccess, + }), + ); +} diff --git a/web/app/env.ts b/web/app/env.ts index b196be2a1fbe..d223e3b7a916 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -222,6 +222,10 @@ export const env = createEnv({ // unavailable. STRIPE_SECRET_KEY: z.string().min(1).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), + // Svix signing secret (`whsec_...`) for the Stack Auth webhook endpoint + // `/api/webhooks/stack`. Optional: when unset the route answers 503 and + // team removal is enforced only by the reconcile cron. + STACK_WEBHOOK_SECRET: z.string().min(1).optional(), // Price-id overrides carry the amount in their name, and every retired // name fails env validation instead of silently pinning checkout to a // grandfathered Price (Stripe amounts are immutable; see plans.ts). @@ -444,6 +448,7 @@ export const env = createEnv({ CMUX_PRO_FROM_EMAIL: trimEnv(process.env.CMUX_PRO_FROM_EMAIL), STRIPE_SECRET_KEY: trimEnv(process.env.STRIPE_SECRET_KEY), STRIPE_WEBHOOK_SECRET: trimEnv(process.env.STRIPE_WEBHOOK_SECRET), + STACK_WEBHOOK_SECRET: trimEnv(process.env.STACK_WEBHOOK_SECRET), STRIPE_PRO_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_PRICE_ID), STRIPE_PRO_MONTHLY_50_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_50_PRICE_ID), STRIPE_PRO_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_YEARLY_PRICE_ID), diff --git a/web/services/auth/README.md b/web/services/auth/README.md index d3ca3c87782d..6b464ee10e10 100644 --- a/web/services/auth/README.md +++ b/web/services/auth/README.md @@ -19,10 +19,12 @@ and is deleted on sign-out. Account deletion is enforced on read instead: the snapshot path checks the deletion tombstone directly on every request, so a tombstone takes effect immediately rather than after the TTL. -The TTL is the security parameter here. A user removed from a team keeps that -team's device-registry access until their snapshot refreshes, because Stack -sends no webhook we could use to invalidate it. Ten minutes bounds that at one -Stack call per active user per ten minutes, under 7 a second fleet-wide. +The TTL is the security parameter here. When a user is removed from a team, +Stack's `team_membership.deleted` webhook (`app/api/webhooks/stack`, secret +`STACK_WEBHOOK_SECRET`) deletes their snapshot and detaches their tunnels from +the team's network. The TTL bounds exposure only when that delivery is missed +or still retrying. Ten minutes costs one Stack call per active user per ten +minutes, under 7 a second fleet-wide. ## Measuring it diff --git a/web/services/auth/identitySnapshot.ts b/web/services/auth/identitySnapshot.ts index b94deaf56f92..ba71490979bc 100644 --- a/web/services/auth/identitySnapshot.ts +++ b/web/services/auth/identitySnapshot.ts @@ -148,19 +148,24 @@ export async function writeIdentitySnapshot( } /** - * Forget the stored identity for a user. Called when a session is revoked or - * the account is deleted, so no instance can keep answering from a snapshot - * the user just invalidated. + * Forget the stored identity for a user. Called when a session is revoked, the + * account is deleted, or the user leaves a team, so no instance can keep + * answering from a snapshot the user just invalidated. + * + * Best effort by default. `throwOnError` is for revocation callers that must + * report failure so the event is retried (the Stack membership webhook). */ export async function deleteIdentitySnapshot( userId: string, db?: SnapshotDb, + options: { readonly throwOnError?: boolean } = {}, ): Promise { try { await (db ?? cloudDb()) .delete(stackIdentitySnapshots) .where(eq(stackIdentitySnapshots.userId, userId)); - } catch { + } catch (error) { + if (options.throwOnError) throw error; // Best effort: the snapshot expires on its own within the TTL. } } diff --git a/web/services/auth/stackWebhook.ts b/web/services/auth/stackWebhook.ts new file mode 100644 index 000000000000..327eb22f54e8 --- /dev/null +++ b/web/services/auth/stackWebhook.ts @@ -0,0 +1,154 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Stack Auth webhooks, delivered by Svix. + * + * Signature scheme (https://docs.svix.com/receiving/verifying-payloads/how-manual): + * the signed content is `${svix-id}.${svix-timestamp}.${rawBody}`, the key is + * the base64 secret after the `whsec_` prefix, the MAC is HMAC-SHA256, and + * `svix-signature` is a space-separated list of `v1,` entries (one per + * active secret during rotation). Verified here with node:crypto so the route + * takes no new dependency. + */ + +/** Svix's own default tolerance. A captured delivery replays for at most this long. */ +export const SVIX_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60; + +export type SvixVerification = + | { readonly ok: true } + | { readonly ok: false; readonly reason: "missing_headers" | "invalid_secret" | "stale_timestamp" | "bad_signature" }; + +export function verifySvixSignature(input: { + readonly secret: string; + readonly headers: Headers; + readonly rawBody: string; + readonly nowSeconds?: number; +}): SvixVerification { + const id = input.headers.get("svix-id"); + const timestamp = input.headers.get("svix-timestamp"); + const signatures = input.headers.get("svix-signature"); + if (!id || !timestamp || !signatures) return { ok: false, reason: "missing_headers" }; + + const key = svixKey(input.secret); + if (!key) return { ok: false, reason: "invalid_secret" }; + + const sentAt = /^\d{1,12}$/.test(timestamp) ? Number(timestamp) : Number.NaN; + const now = input.nowSeconds ?? Math.floor(Date.now() / 1000); + if (!Number.isFinite(sentAt) || Math.abs(now - sentAt) > SVIX_TIMESTAMP_TOLERANCE_SECONDS) { + return { ok: false, reason: "stale_timestamp" }; + } + + const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${input.rawBody}`).digest(); + for (const entry of signatures.split(" ")) { + const [version, encoded] = entry.split(",", 2); + if (version !== "v1" || !encoded) continue; + const candidate = Buffer.from(encoded, "base64"); + if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) return { ok: true }; + } + return { ok: false, reason: "bad_signature" }; +} + +function svixKey(secret: string): Buffer | null { + const trimmed = secret.trim(); + const encoded = trimmed.startsWith("whsec_") ? trimmed.slice("whsec_".length) : trimmed; + if (!encoded) return null; + const key = Buffer.from(encoded, "base64"); + return key.length > 0 ? key : null; +} + +/** The Stack events this backend acts on; every other type is acknowledged and ignored. */ +export type StackWebhookEvent = + | { readonly type: "team_membership.deleted"; readonly teamId: string; readonly userId: string } + | { readonly type: "team.deleted"; readonly teamId: string } + | { readonly type: "ignored"; readonly eventType: string } + | { readonly type: "malformed" }; + +/** + * Parse a verified body. Shapes follow Stack's webhook schemas + * (`@hexclave/shared` interface/crud): `team_membership.deleted` carries + * `data.team_id` and `data.user_id`; `team.deleted` carries `data.id`. + */ +export function parseStackWebhookEvent(rawBody: string): StackWebhookEvent { + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return { type: "malformed" }; + } + if (!isRecord(body) || typeof body.type !== "string") return { type: "malformed" }; + const data = isRecord(body.data) ? body.data : {}; + if (body.type === "team_membership.deleted") { + const teamId = nonEmptyString(data.team_id); + const userId = nonEmptyString(data.user_id); + return teamId && userId ? { type: body.type, teamId, userId } : { type: "malformed" }; + } + if (body.type === "team.deleted") { + const teamId = nonEmptyString(data.id); + return teamId ? { type: body.type, teamId } : { type: "malformed" }; + } + return { type: "ignored", eventType: body.type.slice(0, 64) }; +} + +export type StackWebhookDependencies = { + readonly webhookSecret: () => string | undefined; + readonly revokeTeamMemberAccess: (input: { readonly teamId: string; readonly userId: string }) => Promise; + readonly revokeTeamAccess: (input: { readonly teamId: string }) => Promise; + readonly nowSeconds?: () => number; + readonly logError?: (message: string, error: unknown) => void; +}; + +/** + * The POST handler body. Status codes are the retry contract with Svix: any + * non-2xx is retried with backoff, so a failed revocation answers 500, and a + * request we will never accept (bad signature, malformed body) answers 4xx. + */ +export async function handleStackWebhook( + request: Request, + dependencies: StackWebhookDependencies, +): Promise { + const secret = dependencies.webhookSecret()?.trim(); + if (!secret) return json(503, { error: "stack_webhook_not_configured" }); + + const rawBody = await request.text(); + const verification = verifySvixSignature({ + secret, + headers: request.headers, + rawBody, + nowSeconds: dependencies.nowSeconds?.(), + }); + if (!verification.ok) { + const status = verification.reason === "invalid_secret" ? 503 : 401; + return json(status, { error: "invalid_signature", reason: verification.reason }); + } + + const event = parseStackWebhookEvent(rawBody); + try { + switch (event.type) { + case "team_membership.deleted": + await dependencies.revokeTeamMemberAccess({ teamId: event.teamId, userId: event.userId }); + return json(200, { received: true, handled: event.type }); + case "team.deleted": + await dependencies.revokeTeamAccess({ teamId: event.teamId }); + return json(200, { received: true, handled: event.type }); + case "ignored": + return json(200, { received: true, ignored: event.eventType }); + case "malformed": + return json(400, { error: "malformed_event" }); + } + } catch (error) { + (dependencies.logError ?? console.error)(`Stack webhook ${event.type} revocation failed`, error); + return json(500, { error: "revocation_failed" }); + } +} + +function json(status: number, body: Record): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} diff --git a/web/services/vms/auth.ts b/web/services/vms/auth.ts index 3441cc357f66..13b8ee576b62 100644 --- a/web/services/vms/auth.ts +++ b/web/services/vms/auth.ts @@ -220,6 +220,18 @@ export function invalidateNativeAuthCacheForTokens(tokens: NativeStackTokens): v } } +/** + * Drop every cached verification for one user on this instance. The team + * membership webhook calls this so the instance that handles the removal stops + * answering from a cached team list at once. Other instances keep theirs until + * the cache TTL (30 seconds by default) expires. + */ +export function invalidateNativeAuthCacheForUser(userId: string): void { + for (const [key, entry] of nativeAuthCache) { + if (entry.user.id === userId) nativeAuthCache.delete(key); + } +} + // Stack throttles per project, not per caller. Once one native verification is // throttled, every other native verification from this instance fails the same // way for the next few seconds, and the Stack SDK retries each of those calls @@ -602,11 +614,12 @@ async function verifyNativeRequest( * miss (no token, token not locally verifiable, no fresh snapshot) falls * through to `verifyRequest`, which asks Stack and refreshes the snapshot. * - * Trade-off, stated: team membership can be up to the snapshot TTL stale, so a - * user removed from a team keeps that team's device-registry access until the - * row refreshes. Stack sends no membership webhook we could invalidate on, so - * the TTL (default ten minutes) is the bound. Routes that gate money, account - * mutation, or admin powers must keep calling `verifyRequest`. + * Trade-off, stated: team membership can be up to the snapshot TTL stale. The + * Stack `team_membership.deleted` webhook (`app/api/webhooks/stack`) deletes + * the removed user's row, so a delivered webhook ends that window at once; the + * TTL (default ten minutes) remains the bound when a delivery is missed or + * still retrying. Routes that gate money, account mutation, or admin powers + * must keep calling `verifyRequest`. */ export async function verifyRequestFromSnapshot( request: Request, @@ -687,6 +700,30 @@ async function isAccountDeletionTombstoned(userId: string): Promise { isBlockingAccountDeletionTombstone(deletion); } +/** + * The caller's complete team membership, freshly listed from Stack for this + * request, or null when it cannot be established (Stack error, throttle, or no + * session). Never answered from a cache or an identity snapshot. + * + * Tunnel enrollment uses this to decide which team networks to keep: the + * route's normal verification resolves only the selected team, and treating + * that one team as the whole membership would detach every other team. + */ +export async function verifyCompleteTeamMembership( + request: Request, + expectedUserId: string, +): Promise { + try { + const user = await verifyRequest(request, { + requireFreshTeamMembership: true, + forceCompleteTeamList: true, + }); + return user?.id === expectedUserId ? user.teamIds : null; + } catch { + return null; + } +} + export type VerifiedIdentity = { readonly id: string; /** How the identity was established; surfaced for logs and tests. */ @@ -760,6 +797,10 @@ async function resolveStackTeamMembership( ): Promise<{ selectedTeam: BillingTeamLike | null; listedTeams: BillingTeamLike[]; completeTeamList: boolean }> { const selectedTeam = billingTeamFromUnknown(user.selectedTeam); if (options.requireFreshTeamMembership) { + // An empty list here must mean "no teams", never "could not list". + if (typeof user.listTeams !== "function") { + throw new Error("Stack user cannot list teams; fresh team membership is unavailable"); + } const listedTeams = (await listAllStackTeams(user, options.subrouterAuthorizationSignal)) .map(billingTeamFromUnknown).filter((team): team is BillingTeamLike => !!team); return { diff --git a/web/services/vms/privateNetwork.ts b/web/services/vms/privateNetwork.ts index db3a6a6e3b12..cfc1dd4fd041 100644 --- a/web/services/vms/privateNetwork.ts +++ b/web/services/vms/privateNetwork.ts @@ -472,6 +472,12 @@ export function enrollVmTunnel(input: { readonly sessionIssuedAt?: Date | null; readonly clientPublicKey: string; readonly teamIds?: readonly string[]; + /** + * True only when `teamIds` is the caller's complete, freshly listed Stack + * membership. Only then are attachments to other team networks detached; a + * partial list (one selected team) must never cut the caller's other teams. + */ + readonly teamIdsComplete?: boolean; }) { return Effect.gen(function* () { const providers = yield* requirePrivateNetworkingGateway(input.provider); @@ -555,7 +561,7 @@ export function enrollVmTunnel(input: { addressV6: current.addressV6, configIssued: true, }); - const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: current, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input) }); + const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: current, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input), detachStale: input.teamIdsComplete === true }); return describeTunnel(current, row, network, { created: false, rotated }, teamNetworks); } // The control plane has a row for a tunnel the provider no longer has. @@ -581,7 +587,7 @@ export function enrollVmTunnel(input: { addressV4: created.tunnel.addressV4, addressV6: created.tunnel.addressV6, }); - const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: created.tunnel, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input) }); + const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: created.tunnel, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input), detachStale: input.teamIdsComplete === true }); return describeTunnel(created.tunnel, row, network, { created: true, rotated: created.rotated }, teamNetworks); })); }); @@ -594,6 +600,8 @@ export function readVmTunnel(input: { readonly deviceFingerprint: string; readonly tunnelPurpose: "terminal" | "browser"; readonly teamIds?: readonly string[]; + /** See `enrollVmTunnel`: detach other team networks only for a complete list. */ + readonly teamIdsComplete?: boolean; }) { return Effect.gen(function* () { const providers = yield* requirePrivateNetworkingGateway(input.provider); @@ -620,7 +628,7 @@ export function readVmTunnel(input: { new VmTunnelNotFoundError({ deviceFingerprint: input.deviceFingerprint }), ); } - const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: live, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input) }); + const teamNetworks = yield* reconcileTunnelTeamNetworks({ providers, tunnel: live, provider: input.provider, homeNetworkId: network.providerNetworkId, teamIds: teamNetworkCandidates(input), detachStale: input.teamIdsComplete === true }); return describeTunnel(live, existing, network, { created: false, rotated: false }, teamNetworks); }); } @@ -881,14 +889,16 @@ function teamNetworkCandidates(input: { readonly userId: string; readonly teamId } /** - * Attach the tunnel to the network of every team the caller belongs to, and - * detach it from team networks the caller has left. The provider's attachment + * Attach the tunnel to the network of every team the caller belongs to, and, + * when the caller's complete membership is known, detach it from team networks + * the caller has left. The provider's attachment * list is the record: a tunnel is attached exactly when Freestyle says so, and * deleting a tunnel removes its attachments with it. * - * A failed attach is logged and skipped so enrollment never fails on it. When a - * team network lookup fails, stale attachments are kept, because the failed - * lookup might have been a network the caller still belongs to. + * A failed attach is logged and skipped so enrollment never fails on it. When + * the team list is partial, or a team network lookup fails, stale attachments + * are kept, because the missing team might be one the caller still belongs to. + * Removal is handled by the Stack membership webhook and the reconcile cron. */ function reconcileTunnelTeamNetworks(input: { readonly providers: PrivateNetworkingGateway; @@ -896,6 +906,8 @@ function reconcileTunnelTeamNetworks(input: { readonly provider: ProviderId; readonly homeNetworkId: string; readonly teamIds?: readonly string[]; + /** Detach networks outside `teamIds`; only safe when `teamIds` is complete. */ + readonly detachStale: boolean; }): Effect.Effect { return Effect.gen(function* () { const { getNetwork, attachTunnelNetwork } = input.providers; @@ -928,7 +940,7 @@ function reconcileTunnelTeamNetworks(input: { if (ok) attached.push(network); } const detach = input.providers.detachTunnelNetwork; - if (lookupFailed || !detach) return attached; + if (!input.detachStale || lookupFailed || !detach) return attached; // Only the home network and team networks are ever attached, so any other // attachment belongs to a team the caller has left. const keep = new Set([input.homeNetworkId, ...desired.map((network) => network.providerNetworkId)]); diff --git a/web/services/vms/teamMemberRevocation.ts b/web/services/vms/teamMemberRevocation.ts new file mode 100644 index 000000000000..44019160ab56 --- /dev/null +++ b/web/services/vms/teamMemberRevocation.ts @@ -0,0 +1,61 @@ +import { deleteIdentitySnapshot } from "../auth/identitySnapshot"; +import { invalidateNativeAuthCacheForUser } from "./auth"; +import { revokeTeamNetworkAccess, type TeamNetworkRevocationResult } from "./teamNetworkAccess"; +import { runVmWorkflow } from "./workflows"; + +/** + * What removing someone from a team revokes, in one place. + * + * - Their tunnels leave the team's private network. Team machines trust that + * network, so this cuts open terminals, browsers, and desktop sessions, not + * only new ones. + * - Their identity snapshot is deleted, so every snapshot-backed check asks + * Stack again instead of reusing a team list up to ten minutes old. + * - This instance's native verification cache drops their entries. + * + * Endpoint lease rows are not touched: on Freestyle their tokens are ledger + * entries only (the network is the credential), and the provider's lease + * revocation is per machine, which would also cut the team's other members. + * Team publications are team resources and outlive any one member. + */ +export type TeamRevocationDependencies = { + readonly revokeNetworkAccess: (input: { readonly teamId: string; readonly userId?: string }) => Promise; + readonly deleteIdentitySnapshot: (userId: string) => Promise; + readonly invalidateAuthCache: (userId: string) => void; +}; + +const defaultDependencies: TeamRevocationDependencies = { + revokeNetworkAccess: (input) => runVmWorkflow(revokeTeamNetworkAccess(input)), + deleteIdentitySnapshot: (userId) => deleteIdentitySnapshot(userId, undefined, { throwOnError: true }), + invalidateAuthCache: invalidateNativeAuthCacheForUser, +}; + +/** + * Revoke one member's access to one team's machines. Idempotent. Both steps + * always run; the call throws when either failed so the caller can retry. + */ +export async function revokeTeamMemberAccess( + input: { readonly teamId: string; readonly userId: string }, + dependencies: TeamRevocationDependencies = defaultDependencies, +): Promise { + dependencies.invalidateAuthCache(input.userId); + const [network, snapshot] = await Promise.allSettled([ + dependencies.revokeNetworkAccess({ teamId: input.teamId, userId: input.userId }), + dependencies.deleteIdentitySnapshot(input.userId), + ]); + if (network.status === "rejected") throw network.reason; + if (snapshot.status === "rejected") throw snapshot.reason; + return network.value; +} + +/** + * A deleted team: every tunnel leaves its network. Members' snapshots expire on + * their own; with the network gone from every tunnel, a stale team id in a + * snapshot reaches nothing. + */ +export async function revokeTeamAccess( + input: { readonly teamId: string }, + dependencies: TeamRevocationDependencies = defaultDependencies, +): Promise { + return await dependencies.revokeNetworkAccess({ teamId: input.teamId }); +} diff --git a/web/services/vms/teamNetworkAccess.ts b/web/services/vms/teamNetworkAccess.ts new file mode 100644 index 000000000000..513c6837bae4 --- /dev/null +++ b/web/services/vms/teamNetworkAccess.ts @@ -0,0 +1,111 @@ +import * as Effect from "effect/Effect"; +import type { ProviderId } from "./drivers"; +import { VmProviderOperationError, type VmDatabaseError } from "./errors"; +import { networkSlugForTeam } from "./privateNetwork"; +import { VmProviderGateway, type VmProviderGatewayShape } from "./providerGateway"; +import { VmRepository, type CloudVmTunnelRow, type VmRepositoryShape } from "./repository"; + +/** + * Team network membership for enrolled computers. + * + * A team's machines trust their private network: cmux-tui and the desktop + * accept any peer that can send packets on it. A computer reaches a team's + * machines only while its tunnel is attached to that team's network, so + * detaching the tunnel is what revokes access, including terminals and + * browsers that are already open. + */ + +type TunnelOwner = Pick; + +export type NetworkTunnelDetachResult = { + /** Provider tunnel ids detached from the network. */ + readonly detached: readonly string[]; + /** Selected tunnels whose detach failed. */ + readonly failed: readonly string[]; +}; + +/** + * Detach the network's tunnels that `select` picks. Tunnels with no row in this + * database are never touched, because the provider account can hold tunnels + * another environment issued. Every selected tunnel is attempted; a failed + * detach is reported in `failed` instead of stopping the rest. + */ +export function detachNetworkTunnels(input: { + readonly repo: Pick, "findTunnelsByProviderTunnelIds">; + readonly providers: Pick, "listNetworkTunnelIds" | "detachTunnelNetwork">; + readonly provider: ProviderId; + readonly networkId: string; + readonly select: (tunnel: TunnelOwner) => boolean; +}): Effect.Effect { + return Effect.gen(function* () { + const tunnelIds = yield* input.providers.listNetworkTunnelIds(input.provider, input.networkId); + const rows = yield* input.repo.findTunnelsByProviderTunnelIds(input.provider, tunnelIds); + const detached: string[] = []; + const failed: string[] = []; + for (const row of rows) { + if (!input.select(row)) continue; + const ok = yield* input.providers.detachTunnelNetwork(input.provider, row.providerTunnelId, input.networkId).pipe( + Effect.as(true), + Effect.catchAll((error) => Effect.logWarning("Cloud team tunnel detach failed", { + networkId: input.networkId, + tunnelId: row.providerTunnelId, + error, + }).pipe(Effect.as(false))), + ); + (ok ? detached : failed).push(row.providerTunnelId); + } + return { detached, failed }; + }); +} + +/** Providers whose team networks exist. Freestyle is the only one today. */ +const TEAM_NETWORK_PROVIDERS: readonly ProviderId[] = ["freestyle"]; + +export type TeamNetworkRevocationResult = { + readonly detached: number; +}; + +/** + * Detach one user's tunnels (or, with no `userId`, every tunnel) from the + * team's network. Idempotent: a tunnel that is no longer attached is not + * listed by the provider, and a team with no network has nothing to detach. + * + * Fails when the network lookup, tunnel listing, or any selected detach fails, + * so a webhook caller answers with an error and the sender retries. + */ +export function revokeTeamNetworkAccess(input: { + readonly teamId: string; + readonly userId?: string; +}): Effect.Effect { + return Effect.gen(function* () { + const repo = yield* VmRepository; + const providers = yield* VmProviderGateway; + const { findTunnelsByProviderTunnelIds } = repo; + const { getNetwork, listNetworkTunnelIds, detachTunnelNetwork } = providers; + if (!findTunnelsByProviderTunnelIds || !getNetwork || !listNetworkTunnelIds || !detachTunnelNetwork) { + return { detached: 0 }; + } + let detached = 0; + for (const provider of TEAM_NETWORK_PROVIDERS) { + if (!providers.supportsPrivateNetworking?.(provider)) continue; + const network = yield* getNetwork(provider, networkSlugForTeam(input.teamId)); + if (!network) continue; + const result = yield* detachNetworkTunnels({ + repo: { findTunnelsByProviderTunnelIds }, + providers: { listNetworkTunnelIds, detachTunnelNetwork }, + provider, + networkId: network.id, + select: (tunnel) => input.userId === undefined || tunnel.userId === input.userId, + }); + detached += result.detached.length; + if (result.failed.length > 0) { + return yield* Effect.fail(new VmProviderOperationError({ + provider, + operation: "detachTunnelNetwork", + cause: new Error(`${result.failed.length} team tunnel detach(es) failed for network ${network.id}`), + })); + } + } + return { detached }; + }); +} diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 82a700bf8b29..2236d538d14e 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -96,6 +96,7 @@ import { getGoVmUsage, GO_INCLUDED_VM_HOURS } from "./goUsage"; import { GO_PAUSE_INTENT_KEY, pauseGoVm } from "./goPause"; import { networkSlugForTeam, networkSlugForUser, privateNetworkUnavailableReason, resolveOwnerNetwork } from "./privateNetwork"; import { listTeamMemberIdsWithTimeout, type VmTeamDirectory } from "./teamDirectory"; +import { detachNetworkTunnels } from "./teamNetworkAccess"; import { isProviderDeletionConfirmed, isProviderIdentityNotFoundError, isProviderNotFoundError } from "./providerErrors"; import { VmProviderGateway, VmProviderGatewayLive, type VmProviderGatewayShape } from "./providerGateway"; import { isProviderCreateCleanupError } from "./drivers/providerCreateCleanup"; @@ -419,6 +420,9 @@ export function renameVm(input: { * Detach tunnels from team networks their owner no longer belongs to. Freestyle * is the record of both the team networks (found by slug) and their attached * tunnels; candidate teams come from the live machines billed to them. + * + * This is the backstop. The Stack webhook (`app/api/webhooks/stack`) detaches + * a removed member at once; this pass catches missed or failed deliveries. */ function reconcileTeamTunnelAttachments( repo: VmRepositoryShape, @@ -436,15 +440,15 @@ function reconcileTeamTunnelAttachments( const membersResult = yield* listTeamMemberIdsWithTimeout(directory, owner.teamId, timeoutMs); if ("error" in membersResult) return; const members = membersResult.memberIds ? new Set(membersResult.memberIds) : null; - const tunnelIds = yield* providers.listNetworkTunnelIds!(owner.provider, network.id); - const rows = yield* repo.findTunnelsByProviderTunnelIds!(owner.provider, tunnelIds); - for (const row of rows) { - // Tunnels with no row are skipped: the provider account can hold - // tunnels another environment issued. - const remove = members === null || row.revokedAt !== null || !members.has(row.userId); - if (!remove) continue; - yield* providers.detachTunnelNetwork!(owner.provider, row.providerTunnelId, network.id).pipe(Effect.catchAll(() => Effect.void)); - } + // Tunnels with no row are skipped inside detachNetworkTunnels; failed + // detaches are retried by the next run. + yield* detachNetworkTunnels({ + repo: { findTunnelsByProviderTunnelIds: repo.findTunnelsByProviderTunnelIds! }, + providers: { listNetworkTunnelIds: providers.listNetworkTunnelIds!, detachTunnelNetwork: providers.detachTunnelNetwork! }, + provider: owner.provider, + networkId: network.id, + select: (row) => members === null || row.revokedAt !== null || !members.has(row.userId), + }); }).pipe(Effect.catchAll(() => Effect.void)); return Effect.gen(function* () { const startedAt = now(); diff --git a/web/tests/stack-webhook-team-revocation.test.ts b/web/tests/stack-webhook-team-revocation.test.ts new file mode 100644 index 000000000000..3680bcb2fdea --- /dev/null +++ b/web/tests/stack-webhook-team-revocation.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + handleStackWebhook, + parseStackWebhookEvent, + verifySvixSignature, + type StackWebhookDependencies, +} from "../services/auth/stackWebhook"; +import { VmProviderOperationError } from "../services/vms/errors"; +import { networkSlugForTeam } from "../services/vms/privateNetwork"; +import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; +import { VmRepository, type VmRepositoryShape } from "../services/vms/repository"; +import { revokeTeamNetworkAccess } from "../services/vms/teamNetworkAccess"; +import { revokeTeamAccess, revokeTeamMemberAccess, type TeamRevocationDependencies } from "../services/vms/teamMemberRevocation"; + +const KEY = Buffer.from("stack-webhook-test-key-0123456789"); +const SECRET = `whsec_${KEY.toString("base64")}`; +const NOW = 1_800_000_000; + +function sign(id: string, timestamp: number, body: string, key = KEY): string { + return createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"); +} + +function signedHeaders(body: string, options: { timestamp?: number; signature?: string } = {}): Headers { + const timestamp = options.timestamp ?? NOW; + return new Headers({ + "content-type": "application/json", + "svix-id": "msg_1", + "svix-timestamp": String(timestamp), + "svix-signature": options.signature ?? `v1,${sign("msg_1", timestamp, body)}`, + }); +} + +describe("Svix signature verification", () => { + const body = JSON.stringify({ type: "team_membership.deleted", data: { team_id: "team-1", user_id: "user-1" } }); + + test("accepts a valid signature", () => { + expect(verifySvixSignature({ secret: SECRET, headers: signedHeaders(body), rawBody: body, nowSeconds: NOW })).toEqual({ ok: true }); + }); + + test("accepts any matching entry in a rotated signature list", () => { + const signature = `v1,${sign("msg_1", NOW, body, Buffer.from("old-key"))} v1,${sign("msg_1", NOW, body)}`; + expect(verifySvixSignature({ secret: SECRET, headers: signedHeaders(body, { signature }), rawBody: body, nowSeconds: NOW }).ok).toBe(true); + }); + + test("rejects a bad signature and a tampered body", () => { + const bad = signedHeaders(body, { signature: `v1,${Buffer.alloc(32).toString("base64")}` }); + expect(verifySvixSignature({ secret: SECRET, headers: bad, rawBody: body, nowSeconds: NOW })).toEqual({ ok: false, reason: "bad_signature" }); + expect(verifySvixSignature({ secret: SECRET, headers: signedHeaders(body), rawBody: `${body} `, nowSeconds: NOW })).toEqual({ ok: false, reason: "bad_signature" }); + const wrongVersion = signedHeaders(body, { signature: `v2,${sign("msg_1", NOW, body)}` }); + expect(verifySvixSignature({ secret: SECRET, headers: wrongVersion, rawBody: body, nowSeconds: NOW }).ok).toBe(false); + }); + + test("rejects timestamps outside five minutes either way", () => { + for (const timestamp of [NOW - 301, NOW + 301]) { + expect(verifySvixSignature({ secret: SECRET, headers: signedHeaders(body, { timestamp }), rawBody: body, nowSeconds: NOW })) + .toEqual({ ok: false, reason: "stale_timestamp" }); + } + expect(verifySvixSignature({ secret: SECRET, headers: signedHeaders(body, { timestamp: NOW - 299 }), rawBody: body, nowSeconds: NOW }).ok).toBe(true); + }); + + test("rejects missing headers", () => { + const headers = signedHeaders(body); + headers.delete("svix-signature"); + expect(verifySvixSignature({ secret: SECRET, headers, rawBody: body, nowSeconds: NOW })).toEqual({ ok: false, reason: "missing_headers" }); + }); +}); + +describe("Stack webhook dispatch", () => { + function harness(overrides: Partial = {}) { + const calls: string[] = []; + const dependencies: StackWebhookDependencies = { + webhookSecret: () => SECRET, + nowSeconds: () => NOW, + revokeTeamMemberAccess: async ({ teamId, userId }) => { calls.push(`member:${teamId}:${userId}`); }, + revokeTeamAccess: async ({ teamId }) => { calls.push(`team:${teamId}`); }, + logError: () => {}, + ...overrides, + }; + const post = (payload: unknown, headers?: Headers) => { + const body = JSON.stringify(payload); + return handleStackWebhook(new Request("https://cmux.test/api/webhooks/stack", { + method: "POST", headers: headers ?? signedHeaders(body), body, + }), dependencies); + }; + return { calls, post }; + } + + test("missing secret answers 503 and processes nothing", async () => { + const { calls, post } = harness({ webhookSecret: () => undefined }); + const response = await post({ type: "team_membership.deleted", data: { team_id: "team-1", user_id: "user-1" } }); + expect(response.status).toBe(503); + expect(calls).toEqual([]); + }); + + test("an unsigned request answers 401 and processes nothing", async () => { + const { calls, post } = harness(); + const response = await post( + { type: "team_membership.deleted", data: { team_id: "team-1", user_id: "user-1" } }, + new Headers({ "svix-id": "msg_1", "svix-timestamp": String(NOW), "svix-signature": "v1,AAAA" }), + ); + expect(response.status).toBe(401); + expect(calls).toEqual([]); + }); + + test("team_membership.deleted revokes that member's access to that team", async () => { + const { calls, post } = harness(); + const response = await post({ type: "team_membership.deleted", data: { team_id: "team-1", user_id: "user-1" } }); + expect(response.status).toBe(200); + expect(calls).toEqual(["member:team-1:user-1"]); + }); + + test("team.deleted revokes every tunnel on the team network", async () => { + const { calls, post } = harness(); + expect((await post({ type: "team.deleted", data: { id: "team-9" } })).status).toBe(200); + expect(calls).toEqual(["team:team-9"]); + }); + + test("other events are acknowledged and ignored; malformed membership events are 400", async () => { + const { calls, post } = harness(); + expect((await post({ type: "user.updated", data: { id: "user-1" } })).status).toBe(200); + expect((await post({ type: "team_membership.deleted", data: { team_id: "team-1" } })).status).toBe(400); + expect(calls).toEqual([]); + expect(parseStackWebhookEvent("not json")).toEqual({ type: "malformed" }); + }); + + test("a failed revocation answers 500 so Svix retries", async () => { + const { post } = harness({ revokeTeamMemberAccess: async () => { throw new Error("provider down"); } }); + const response = await post({ type: "team_membership.deleted", data: { team_id: "team-1", user_id: "user-1" } }); + expect(response.status).toBe(500); + }); +}); + +describe("team network revocation", () => { + type Row = { readonly providerTunnelId: string; readonly userId: string; readonly revokedAt: Date | null }; + const networks: Record = { + [networkSlugForTeam("team-1")]: ["tun-removed-mac", "tun-removed-browser", "tun-other-member", "tun-unknown"], + [networkSlugForTeam("team-2")]: ["tun-removed-team-2"], + }; + const rows: readonly Row[] = [ + { providerTunnelId: "tun-removed-mac", userId: "removed", revokedAt: null }, + { providerTunnelId: "tun-removed-browser", userId: "removed", revokedAt: null }, + { providerTunnelId: "tun-other-member", userId: "member", revokedAt: null }, + { providerTunnelId: "tun-removed-team-2", userId: "removed", revokedAt: null }, + ]; + + function run(input: { teamId: string; userId?: string }, options: { failDetach?: string } = {}) { + const detached: string[] = []; + const repo = { + findTunnelsByProviderTunnelIds: (_provider: string, ids: readonly string[]) => + Effect.succeed(rows.filter((row) => ids.includes(row.providerTunnelId))), + } as unknown as VmRepositoryShape; + const gateway = { + supportsPrivateNetworking: () => true, + getNetwork: (_provider: string, slug: string) => + Effect.succeed(networks[slug] ? { id: `vpc:${slug}`, slug, cidr: "10.60.0.0/24", cidrV6: null } : null), + listNetworkTunnelIds: (_provider: string, networkId: string) => + Effect.succeed([...(networks[networkId.replace(/^vpc:/, "")] ?? [])]), + detachTunnelNetwork: (_provider: string, tunnelId: string, networkId: string) => tunnelId === options.failDetach + ? Effect.fail(new VmProviderOperationError({ provider: "freestyle", operation: "detachTunnelNetwork", cause: new Error("down") })) + : Effect.sync(() => { detached.push(`${tunnelId}@${networkId}`); }), + } as unknown as VmProviderGatewayShape; + const effect = revokeTeamNetworkAccess(input).pipe( + Effect.provide(Layer.mergeAll(Layer.succeed(VmRepository, repo), Layer.succeed(VmProviderGateway, gateway))), + ); + return { detached, result: Effect.runPromise(Effect.either(effect)) }; + } + + test("detaches only the removed member's tunnels, only from that team's network", async () => { + const { detached, result } = run({ teamId: "team-1", userId: "removed" }); + const outcome = await result; + expect(outcome._tag).toBe("Right"); + const slug = networkSlugForTeam("team-1"); + expect(detached).toEqual([`tun-removed-mac@vpc:${slug}`, `tun-removed-browser@vpc:${slug}`]); + }); + + test("team deletion detaches every known tunnel and leaves unknown tunnels alone", async () => { + const { detached, result } = run({ teamId: "team-1" }); + expect((await result)._tag).toBe("Right"); + expect(detached.map((entry) => entry.split("@")[0])).toEqual(["tun-removed-mac", "tun-removed-browser", "tun-other-member"]); + }); + + test("a team with no network is a no-op", async () => { + const { detached, result } = run({ teamId: "team-none", userId: "removed" }); + expect((await result)._tag).toBe("Right"); + expect(detached).toEqual([]); + }); + + test("a failed detach still attempts the rest, then fails so the caller retries", async () => { + const { detached, result } = run({ teamId: "team-1", userId: "removed" }, { failDetach: "tun-removed-mac" }); + const outcome = await result; + expect(outcome._tag).toBe("Left"); + expect(detached).toEqual([`tun-removed-browser@vpc:${networkSlugForTeam("team-1")}`]); + }); +}); + +describe("revokeTeamMemberAccess", () => { + function dependencies(overrides: Partial = {}) { + const calls: string[] = []; + const deps: TeamRevocationDependencies = { + revokeNetworkAccess: async ({ teamId, userId }) => { calls.push(`network:${teamId}:${userId ?? "*"}`); return { detached: 2 }; }, + deleteIdentitySnapshot: async (userId) => { calls.push(`snapshot:${userId}`); }, + invalidateAuthCache: (userId) => { calls.push(`cache:${userId}`); }, + ...overrides, + }; + return { calls, deps }; + } + + test("detaches the team network and deletes the identity snapshot", async () => { + const { calls, deps } = dependencies(); + expect(await revokeTeamMemberAccess({ teamId: "team-1", userId: "user-1" }, deps)).toEqual({ detached: 2 }); + expect(calls.sort()).toEqual(["cache:user-1", "network:team-1:user-1", "snapshot:user-1"]); + }); + + test("a failed snapshot delete still detaches, then throws for retry", async () => { + const { calls, deps } = dependencies({ deleteIdentitySnapshot: async () => { throw new Error("db down"); } }); + await expect(revokeTeamMemberAccess({ teamId: "team-1", userId: "user-1" }, deps)).rejects.toThrow("db down"); + expect(calls).toContain("network:team-1:user-1"); + }); + + test("a failed detach still deletes the snapshot, then throws for retry", async () => { + const { calls, deps } = dependencies({ revokeNetworkAccess: async () => { throw new Error("provider down"); } }); + await expect(revokeTeamMemberAccess({ teamId: "team-1", userId: "user-1" }, deps)).rejects.toThrow("provider down"); + expect(calls).toContain("snapshot:user-1"); + }); + + test("team deletion revokes the whole network", async () => { + const { calls, deps } = dependencies(); + await revokeTeamAccess({ teamId: "team-1" }, deps); + expect(calls).toEqual(["network:team-1:*"]); + }); +}); diff --git a/web/tests/vm-private-network.test.ts b/web/tests/vm-private-network.test.ts index a862a35de5ae..70206a23733c 100644 --- a/web/tests/vm-private-network.test.ts +++ b/web/tests/vm-private-network.test.ts @@ -820,7 +820,7 @@ describe("enrollVmTunnel", () => { describe("team tunnel reconciliation", () => { const TEAM_2: ProviderNetwork = { id: "vpc-team-2", slug: networkSlugForTeam("team-2"), cidr: "10.60.0.0/24", cidrV6: "fd60::/64" }; - function enroll(teamIds: readonly string[] | undefined, repo: VmRepositoryShape, gateway: VmProviderGatewayShape) { + function enroll(teamIds: readonly string[] | undefined, repo: VmRepositoryShape, gateway: VmProviderGatewayShape, teamIdsComplete = true) { return Effect.runPromise(enrollVmTunnel({ userId: "user-1", provider: "freestyle", @@ -828,7 +828,7 @@ describe("team tunnel reconciliation", () => { deviceFingerprint: "device-1", tunnelPurpose: "browser", clientPublicKey: CLIENT_KEY, - ...(teamIds ? { teamIds } : {}), + ...(teamIds ? { teamIds, teamIdsComplete } : {}), }).pipe(Effect.provide(layerFor(repo, gateway)))); } @@ -881,6 +881,31 @@ describe("team tunnel reconciliation", () => { expect(result.networks.map((network) => network.id)).toEqual([NETWORK.id, TEAM_NETWORK.id]); }); + test("a complete multi-team membership keeps every team network attached", async () => { + const calls = newGatewayCalls(); + const live = providerTunnel({ attachments: [ + { networkId: NETWORK.id, addressV4: "10.40.0.2", addressV6: "fd00:40::2" }, + { networkId: TEAM_NETWORK.id, addressV4: "10.50.0.2", addressV6: "fd50::2" }, + { networkId: TEAM_2.id, addressV4: "10.60.0.2", addressV6: "fd60::2" }, + ] }); + const result = await enroll(["team-1", "team-2"], testRepo({ network: networkRow(), tunnel: tunnelRow() }), testGateway({ calls, getTunnel: live, teamNetworks: [TEAM_NETWORK, TEAM_2] })); + expect(calls.attachTunnelNetwork).toEqual([]); + expect(calls.detachTunnelNetwork).toEqual([]); + expect(result.networks.map((network) => network.id)).toEqual([NETWORK.id, TEAM_NETWORK.id, TEAM_2.id]); + }); + + test("a partial team list (selected team only) attaches but never detaches other team networks", async () => { + const calls = newGatewayCalls(); + const live = providerTunnel({ attachments: [ + { networkId: NETWORK.id, addressV4: "10.40.0.2", addressV6: "fd00:40::2" }, + { networkId: TEAM_2.id, addressV4: "10.60.0.2", addressV6: "fd60::2" }, + ] }); + const result = await enroll(["team-1"], testRepo({ network: networkRow(), tunnel: tunnelRow() }), testGateway({ calls, getTunnel: live, teamNetworks: [TEAM_NETWORK, TEAM_2] }), false); + expect(calls.attachTunnelNetwork).toEqual([TEAM_NETWORK.id]); + expect(calls.detachTunnelNetwork).toEqual([]); + expect(result.networks.map((network) => network.id)).toEqual([NETWORK.id, TEAM_NETWORK.id]); + }); + test("a team network lookup failure attaches the rest but keeps every existing attachment", async () => { const calls = newGatewayCalls(); const live = providerTunnel({ attachments: [{ networkId: "vpc-team-gone", addressV4: "10.70.0.2", addressV6: null }] }); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 77cbb0e9953f..a2d3a54ee0ab 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -543,6 +543,61 @@ describe("VM REST auth", () => { expect(runVmWorkflow).toHaveBeenCalled(); }); + test("tunnel POST and GET forward the complete fresh membership, not only the selected team", async () => { + const user = authedStackUser(); + getUser.mockResolvedValue({ + ...user, + listTeams: async () => [{ id: "team-1", clientReadOnlyMetadata: {} }, { id: "team-2", clientReadOnlyMetadata: {} }], + }); + runVmWorkflow.mockResolvedValue({ + tunnelId: "tunnel-test", provider: "freestyle", deviceFingerprint: "device-test", + routes: [], network: { id: "home", cidr: "10.1.0.0/24", cidrV6: null }, + networks: [], created: false, rotated: false, + }); + const payload = Buffer.from(JSON.stringify({ refresh_token_id: "session-test", iat: 1_700_000_000 })).toString("base64url"); + const headers = { authorization: `Bearer access-token.${payload}.test`, "x-stack-refresh-token": "refresh-token" }; + const posted = await tunnelPOST(new Request("https://cmux.test/api/vm/tunnel", { + method: "POST", headers, + body: JSON.stringify({ deviceId: "device-test", deviceFingerprint: "device-test", tunnelPurpose: "browser", clientPublicKey: Buffer.alloc(32, 1).toString("base64") }), + })); + expect(posted.status).toBe(200); + const enrollInput = (enrollVmTunnel as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0] as { teamIds?: readonly string[]; teamIdsComplete?: boolean }; + expect(enrollInput.teamIds).toEqual(["team-1", "team-2"]); + expect(enrollInput.teamIdsComplete).toBe(true); + const read = await tunnelGET(new Request("https://cmux.test/api/vm/tunnel?deviceFingerprint=device-test&tunnelPurpose=browser", { headers })); + expect(read.status).toBe(200); + const readInput = (readVmTunnel as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0] as { teamIds?: readonly string[]; teamIdsComplete?: boolean }; + expect(readInput.teamIds).toEqual(["team-1", "team-2"]); + expect(readInput.teamIdsComplete).toBe(true); + }); + + test("tunnel POST marks the team list incomplete when the fresh membership listing fails", async () => { + let listCalls = 0; + getUser.mockResolvedValue({ + ...authedStackUser(), + listTeams: async () => { + listCalls += 1; + throw new Error("stack unavailable"); + }, + }); + runVmWorkflow.mockResolvedValue({ + tunnelId: "tunnel-test", provider: "freestyle", deviceFingerprint: "device-test", + routes: [], network: { id: "home", cidr: "10.1.0.0/24", cidrV6: null }, + networks: [], created: false, rotated: false, + }); + const payload = Buffer.from(JSON.stringify({ refresh_token_id: "session-test", iat: 1_700_000_000 })).toString("base64url"); + const headers = { authorization: `Bearer access-token.${payload}.test`, "x-stack-refresh-token": "refresh-token" }; + const posted = await tunnelPOST(new Request("https://cmux.test/api/vm/tunnel", { + method: "POST", headers, + body: JSON.stringify({ deviceId: "device-test", deviceFingerprint: "device-test", tunnelPurpose: "browser", clientPublicKey: Buffer.alloc(32, 1).toString("base64") }), + })); + expect(posted.status).toBe(200); + expect(listCalls).toBeGreaterThan(0); + const enrollInput = (enrollVmTunnel as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0] as { teamIds?: readonly string[]; teamIdsComplete?: boolean }; + expect(enrollInput.teamIds).toEqual(["team-1"]); + expect(enrollInput.teamIdsComplete).toBe(false); + }); + test("tunnel POST and GET forward authenticated team membership", async () => { getUser.mockResolvedValue(authedStackUser()); runVmWorkflow.mockResolvedValue({ From 0df96ebfd9dfc7d99c8eb2f171d280895e222027 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:13:43 -0700 Subject: [PATCH 2/3] Keep Cloud surfaces from several teams open across team switches Each Cloud provider, workspace binding and browser panel records its owning team, and every VM request for a live surface sends that team instead of the active one. A team switch now only rescopes the sidebar and new machines; open terminals and browsers of other teams stay connected, including after restore. A 404 vm_not_found, 403 or vm_owner_mismatch is permanent: the pane shows that access was lost instead of freezing on its last frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Link/CloudMachineLinkManager.swift | 21 +- .../VMClient/CloudMachineAccessLoss.swift | 59 ++++ .../CmuxCloud/VMClient/VMClient+Exec.swift | 6 +- .../VMClient/VMClient+ResourceStats.swift | 5 +- .../Sources/CmuxCloud/VMClient/VMClient.swift | 114 ++++++-- .../VMClient/VMRequestTeamBinding.swift | 38 +++ .../CloudMachineAccessLossTests.swift | 32 +++ ...SurfaceOwnershipPolicyCrossTeamTests.swift | 37 +++ Resources/Localizable.xcstrings | 236 ++++++++++++++++ Sources/AppDelegate+TeamScope.swift | 29 +- Sources/Auth/MacAuthComposition.swift | 4 +- Sources/Cloud/CloudTeamScopeObserver.swift | 31 ++- .../Cloud/CloudTuiManualMirrorSession.swift | 12 +- .../CloudTuiManualMirrorStopReason.swift | 57 ++++ Sources/Cloud/MachinesPanelViewModel.swift | 9 +- Sources/CloudTerminalOverlayCoordinator.swift | 25 +- Sources/DockSplitStore+SessionSnapshot.swift | 3 +- Sources/GhosttyTerminalView.swift | 3 +- .../Panels/BrowserPanel+CloudConnection.swift | 10 + Sources/Panels/BrowserPanel.swift | 7 + .../WorkspaceCloudVMBinding+OwningTeam.swift | 31 +++ .../RemoteTui/WorkspaceCloudVMBinding.swift | 7 +- Sources/SessionBrowserPanelSnapshot.swift | 9 +- Sources/SessionPersistence.swift | 7 + Sources/SessionSnapshotImportTrust.swift | 6 +- ...orkspaceRenameService+Reconciliation.swift | 6 +- .../CmuxTuiSurfaceProvider+Hosting.swift | 9 +- .../CmuxTuiSurfaceProvider+Lifecycle.swift | 4 +- .../CmuxTuiSurfaceProvider+PortForward.swift | 6 +- ...uiSurfaceProviderRegistry+Production.swift | 5 + .../CmuxTuiSurfaceProviderRegistry.swift | 216 +++++++++++++-- .../Surfaces/CmuxTuiSurfaceProviders.swift | 81 +++++- .../SurfaceCatalog+NameAuthority.swift | 3 +- .../Workspace+CloudMachineTeams.swift | 31 +++ .../Surfaces/Workspace+CloudPaneRouting.swift | 3 +- .../Workspace+SessionRestoreIdentity.swift | 6 +- Sources/Workspace.swift | 15 +- cmux.xcodeproj/project.pbxproj | 20 ++ cmuxTests/CloudMultiTeamSurfaceTests.swift | 258 ++++++++++++++++++ cmuxTests/CloudRefreshFixture.swift | 7 +- cmuxTests/CloudRefreshURLProtocol.swift | 5 + ...urfaceProviderRegistryDiscoveryTests.swift | 36 +-- ...ientReadCoalescingTests+ExplicitTeam.swift | 74 +++++ 43 files changed, 1455 insertions(+), 128 deletions(-) create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/CloudMachineAccessLoss.swift create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMRequestTeamBinding.swift create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineAccessLossTests.swift create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SurfaceOwnershipPolicyCrossTeamTests.swift create mode 100644 Sources/Cloud/CloudTuiManualMirrorStopReason.swift create mode 100644 Sources/RemoteTui/WorkspaceCloudVMBinding+OwningTeam.swift create mode 100644 Sources/Surfaces/Workspace+CloudMachineTeams.swift create mode 100644 cmuxTests/CloudMultiTeamSurfaceTests.swift create mode 100644 cmuxTests/VMClientReadCoalescingTests+ExplicitTeam.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLinkManager.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLinkManager.swift index 861d71bc0b5c..566026826aed 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLinkManager.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLinkManager.swift @@ -62,6 +62,10 @@ public actor CloudMachineLinkManager { /// reconnects with this local fact and does not call the attach endpoint. private var privateRoutes: [String: String] = [:] private var privateAddressCandidates: [String: [String]] = [:] + /// The team that owns each machine, captured when its provider was + /// registered. Control-plane calls a link makes name this team, so a link + /// to another team's machine keeps working after the selected team changes. + private var ownerTeams: [String: String] = [:] private var links: [String: CloudMachineLink] = [:] private var connecting: [String: Task] = [:] private var browserProxies: [String: CloudBrowserProxyProcess] = [:] @@ -150,6 +154,16 @@ public actor CloudMachineLinkManager { privateRoutes[machineID] = "ws://\(host):1337/v1/link" } + /// Records the team that owns `machineID`; nil clears it (selected team). + public func setOwnerTeam(_ teamID: String?, for machineID: String) { + ownerTeams[machineID] = teamID.flatMap { $0.isEmpty ? nil : $0 } + } + + /// The owning team recorded for `machineID`, if any. + public func ownerTeam(for machineID: String) -> String? { + ownerTeams[machineID] + } + public func privateAddresses(for machineID: String) -> [String] { privateAddressCandidates[machineID] ?? [] } @@ -231,7 +245,8 @@ public actor CloudMachineLinkManager { let endpoint = try await client.openCmuxRemote( id: machineID, deviceFingerprint: nil, - clientCapabilities: capabilities + clientCapabilities: capabilities, + teamID: self.ownerTeam(for: machineID) ) session = endpoint.session guard endpoint.trustedCarrier else { @@ -368,7 +383,8 @@ public actor CloudMachineLinkManager { let endpoint = try await client.openCmuxRemote( id: machineID, deviceFingerprint: nil, - clientCapabilities: self.resolvedClientCapabilities(clientURL: clientURL) + clientCapabilities: self.resolvedClientCapabilities(clientURL: clientURL), + teamID: self.ownerTeam(for: machineID) ) guard endpoint.trustedCarrier else { throw ManagerError.retryLater(String( @@ -504,6 +520,7 @@ public actor CloudMachineLinkManager { public func retainAddresses(machineIDs: Set) { privateRoutes = privateRoutes.filter { machineIDs.contains($0.key) } privateAddressCandidates = privateAddressCandidates.filter { machineIDs.contains($0.key) } + ownerTeams = ownerTeams.filter { machineIDs.contains($0.key) } } /// Re-sends this Mac's theme to every connected machine (a Ghostty config reload diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/CloudMachineAccessLoss.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/CloudMachineAccessLoss.swift new file mode 100644 index 000000000000..6e1378fc53f1 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/CloudMachineAccessLoss.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Classifies control-plane answers that mean the signed-in user can no longer +/// reach a Cloud machine. +/// +/// A permanent loss ends automatic reconnects: retrying cannot succeed until +/// the user regains access, and a pane that keeps retrying would only show a +/// frozen frame. Transient failures (timeouts, 5xx, throttling, transport +/// errors) are never permanent. +/// +/// ```swift +/// if CloudMachineAccessLoss(error: error) != nil { +/// provider.noteAccessLost() +/// } +/// ``` +public enum CloudMachineAccessLoss: Equatable, Sendable { + /// The machine no longer exists for this user (`404 vm_not_found`). + case notFound + /// The user is no longer allowed to use the machine (`403`), including + /// removal from the machine's team. + case forbidden + /// The machine belongs to an owner the request is not authorized for + /// (`vm_owner_mismatch`, any status). + case ownerMismatch + + /// Classifies `error`; nil when it is not a permanent access loss. + /// + /// - Parameter error: An error thrown by ``VMClient``. + public init?(error: Error) { + guard case let VMClientError.httpStatus(status, body) = error else { return nil } + self.init(status: status, body: body) + } + + /// Classifies an HTTP status and response body; nil when transient. + /// + /// - Parameters: + /// - status: The HTTP status code. + /// - body: The response body, whose JSON `error` field carries the code. + public init?(status: Int, body: String) { + let code = Self.errorCode(body) + if code == "vm_owner_mismatch" { + self = .ownerMismatch + } else if status == 403 { + self = .forbidden + } else if status == 404, code == "vm_not_found" { + self = .notFound + } else { + return nil + } + } + + private static func errorCode(_ body: String) -> String? { + guard let data = body.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let code = object["error"] as? String else { return nil } + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+Exec.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+Exec.swift index 4bbab1d2d0e7..cb4596292893 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+Exec.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+Exec.swift @@ -9,9 +9,10 @@ extension VMClient { /// - command: The command to execute on the VM. /// - timeoutMs: The guest execution deadline in milliseconds. /// - expectedTeamScope: Captured authorization scope that must remain current throughout the request. + /// - teamID: The team that owns the VM; nil uses the selected team. /// - Returns: The command's exit code, standard output, and standard error. /// - Throws: An authorization, transport, or response error if execution cannot complete. - public func exec(id: String, command: String, timeoutMs: Int = 30_000, expectedTeamScope: AuthenticatedTeamScope? = nil) async throws -> VMExecResult { + public func exec(id: String, command: String, timeoutMs: Int = 30_000, expectedTeamScope: AuthenticatedTeamScope? = nil, teamID: String? = nil) async throws -> VMExecResult { return try await withOperation(.exec, foreground: true) { let body: [String: Any] = ["command": command, "timeoutMs": timeoutMs] let encodedID = try pathSegment(id, fieldName: "vm id") @@ -19,7 +20,8 @@ extension VMClient { "POST", path: "/api/vm/\(encodedID)/exec", jsonBody: body, - timeoutSeconds: max(1, Double(timeoutMs) / 1000.0 + 5.0), expectedTeamScope: expectedTeamScope + timeoutSeconds: max(1, Double(timeoutMs) / 1000.0 + 5.0), expectedTeamScope: expectedTeamScope, + teamID: teamID ) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+ResourceStats.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+ResourceStats.swift index 517540faf252..38546abbc684 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+ResourceStats.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+ResourceStats.swift @@ -2,12 +2,13 @@ import Foundation extension VMClient { /// All callers share revisioned resource state, including CLI and sidebar reads. - public func stats(id: String) async throws -> VMStats { + /// `teamID` names the machine's owning team (nil: the selected team). + public func stats(id: String, teamID: String? = nil) async throws -> VMStats { let read = await resourceStats.beginRead(machineID: id) do { let stats = try await withOperation(.stats, foreground: false) { let encodedID = try pathSegment(id, fieldName: "vm id") - let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/stats", timeoutSeconds: 30) + let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/stats", timeoutSeconds: 30, teamID: teamID) try ensureOK(http, data: data) return VMStats(json: try decodeJSONObject(data)) } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift index 45238cd4c198..60c5dfc48765 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift @@ -1649,10 +1649,11 @@ public actor VMClient { return summary } - public func status(id: String) async throws -> VMSummary { + /// Reads one machine. `teamID` names the owning team (nil: the selected team). + public func status(id: String, teamID: String? = nil) async throws -> VMSummary { return try await withOperation(.status, foreground: false) { let encodedID = try pathSegment(id, fieldName: "vm id") - let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)") + let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)", teamID: teamID) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) guard let id = obj["id"] as? String, let provider = obj["provider"] as? String, let image = obj["image"] as? String else { @@ -1708,27 +1709,28 @@ public actor VMClient { /// `POST /api/vm//pause`: park the machine — compute stops (and stops billing), the /// volume, workspaces and terminal history stay. Returns the status the control plane /// now reports. A provider that cannot pause answers 501 `vm_pause_unsupported`. - public func pause(id: String) async throws -> String { + public func pause(id: String, teamID: String? = nil) async throws -> String { return try await withOperation(.pause, foreground: true) { - try await lifecycleTransition(id: id, action: "pause") + try await lifecycleTransition(id: id, action: "pause", teamID: teamID) } } /// `POST /api/vm//resume`: wake a paused machine; the daemon, its terminals and /// files come back. Plan limits apply exactly as they do to an implicit wake. - public func resume(id: String) async throws -> String { + public func resume(id: String, teamID: String? = nil) async throws -> String { return try await withOperation(.resume, foreground: true) { - try await lifecycleTransition(id: id, action: "resume") + try await lifecycleTransition(id: id, action: "resume", teamID: teamID) } } - private func lifecycleTransition(id: String, action: String) async throws -> String { + private func lifecycleTransition(id: String, action: String, teamID: String?) async throws -> String { let encodedID = try pathSegment(id, fieldName: "vm id") let (data, http) = try await request( "POST", path: "/api/vm/\(encodedID)/\(action)", jsonBody: [:], - timeoutSeconds: Self.createTimeoutSeconds + timeoutSeconds: Self.createTimeoutSeconds, + teamID: teamID ) if http.statusCode == 501 { throw VMClientError.lifecycleUnsupported(action: action) @@ -1941,7 +1943,8 @@ public actor VMClient { requireDaemon: Bool = false, sessionId: String? = nil, attachmentId: String? = nil, - title: String? = nil + title: String? = nil, + teamID: String? = nil ) async throws -> VMAttachEndpoint { return try await withOperation(.open, foreground: true) { let encodedID = try pathSegment(id, fieldName: "vm id") @@ -1960,7 +1963,8 @@ public actor VMClient { path: "/api/vm/\(encodedID)/attach-endpoint", jsonBody: body, timeoutSeconds: Self.attachTimeoutSeconds, - retryTransientServiceUnavailable: true + retryTransientServiceUnavailable: true, + teamID: teamID ) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) @@ -1988,7 +1992,8 @@ public actor VMClient { public func openCmuxRemote( id: String, deviceFingerprint: String? = nil, - clientCapabilities: [String] = [] + clientCapabilities: [String] = [], + teamID: String? = nil ) async throws -> VMCmuxRemoteEndpoint { return try await withOperation(.open, foreground: true) { let encodedID = try pathSegment(id, fieldName: "vm id") @@ -2007,7 +2012,8 @@ public actor VMClient { "POST", path: "/api/vm/\(encodedID)/attach-endpoint", jsonBody: body, - timeoutSeconds: 20 + timeoutSeconds: 20, + teamID: teamID ) try ensureOK(http, data: data) return try decodeJSONObject(data) @@ -2089,7 +2095,9 @@ public actor VMClient { ] where value?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { body[key] = value } - let (data, http) = try await request("POST", path: "/api/vm/tunnel", jsonBody: body) + // Enrollment is user-scoped: the server attaches this peer to every + // team network the user belongs to, so a team switch never cancels it. + let (data, http) = try await request("POST", path: "/api/vm/tunnel", jsonBody: body, teamBinding: .user) try ensureOK(http, data: data) return try Self.decodeTunnelEndpoint( decodeJSONObject(data), @@ -2182,10 +2190,10 @@ public actor VMClient { ) } - public func listSessions(id: String) async throws -> [VMCloudSession] { + public func listSessions(id: String, teamID: String? = nil) async throws -> [VMCloudSession] { return try await withOperation(.session, foreground: true) { let encodedID = try pathSegment(id, fieldName: "vm id") - let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/sessions") + let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/sessions", teamID: teamID) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) let rawSessions = obj["sessions"] as? [[String: Any]] ?? [] @@ -2197,7 +2205,8 @@ public actor VMClient { id: String, sessionId: String? = nil, attachmentId: String? = nil, - title: String? = nil + title: String? = nil, + teamID: String? = nil ) async throws -> VMCloudSessionAttach { return try await withOperation(.session, foreground: true) { let encodedID = try pathSegment(id, fieldName: "vm id") @@ -2215,7 +2224,8 @@ public actor VMClient { "POST", path: "/api/vm/\(encodedID)/sessions", jsonBody: body, - timeoutSeconds: Self.attachTimeoutSeconds + timeoutSeconds: Self.attachTimeoutSeconds, + teamID: teamID ) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) @@ -2284,14 +2294,15 @@ public actor VMClient { - public func openPort(id: String, port: Int) async throws -> VMOpenPortEndpoint { + public func openPort(id: String, port: Int, teamID: String? = nil) async throws -> VMOpenPortEndpoint { return try await withOperation(.port, foreground: true) { let encodedID = try pathSegment(id, fieldName: "vm id") let (data, http) = try await request( "POST", path: "/api/vm/\(encodedID)/open-port", jsonBody: ["port": port], - timeoutSeconds: 120 + timeoutSeconds: 120, + teamID: teamID ) try ensureOK(http, data: data) let obj = try decodeJSONObject(data) @@ -2371,6 +2382,14 @@ public actor VMClient { return try await operations.perform(kind, foreground: foreground, work) } + /// Sends one control-plane request. + /// + /// - Parameter teamID: The team that owns the target resource. When set, the + /// request carries it in `X-Cmux-Team-Id`, shared reads coalesce per owning + /// team, and a later change of the selected team does not cancel the + /// request: the server verifies membership in that team per request. When + /// nil, the request is bound to the currently selected team and fails if + /// the selection changes before it completes. public func request( _ method: String, path: String, @@ -2378,7 +2397,26 @@ public actor VMClient { extraHeaders: [String: String] = [:], timeoutSeconds: TimeInterval? = nil, retryTransientServiceUnavailable: Bool = false, - allowedUnderManagedPolicy: Bool = false, expectedTeamScope: AuthenticatedTeamScope? = nil + allowedUnderManagedPolicy: Bool = false, expectedTeamScope: AuthenticatedTeamScope? = nil, + teamID: String? = nil + ) async throws -> (Data, HTTPURLResponse) { + try await request( + method, path: path, jsonBody: jsonBody, extraHeaders: extraHeaders, + timeoutSeconds: timeoutSeconds, retryTransientServiceUnavailable: retryTransientServiceUnavailable, + allowedUnderManagedPolicy: allowedUnderManagedPolicy, expectedTeamScope: expectedTeamScope, + teamBinding: VMRequestTeamBinding(explicitTeamID: teamID) + ) + } + + func request( + _ method: String, + path: String, + jsonBody: [String: Any]? = nil, + extraHeaders: [String: String] = [:], + timeoutSeconds: TimeInterval? = nil, + retryTransientServiceUnavailable: Bool = false, + allowedUnderManagedPolicy: Bool = false, expectedTeamScope: AuthenticatedTeamScope? = nil, + teamBinding: VMRequestTeamBinding ) async throws -> (Data, HTTPURLResponse) { let work = { let isSharedRead = method == "GET" @@ -2390,7 +2428,8 @@ public actor VMClient { if !isSharedRead { return try await self.requestMeasured(method, path: path, jsonBody: jsonBody, extraHeaders: extraHeaders, timeoutSeconds: timeoutSeconds, retryTransientServiceUnavailable: retryTransientServiceUnavailable, - allowedUnderManagedPolicy: allowedUnderManagedPolicy, expectedTeamScope: expectedTeamScope) + allowedUnderManagedPolicy: allowedUnderManagedPolicy, expectedTeamScope: expectedTeamScope, + teamBinding: teamBinding) } try Task.checkCancellation() if let expectedTeamScope, !(await self.auth.isAuthenticatedTeamScopeCurrent(expectedTeamScope)) { @@ -2409,7 +2448,9 @@ public actor VMClient { } throw VMClientError.notSignedIn } - let teamID = await self.auth.resolvedTeamID + let explicitTeamID = teamBinding.explicitTeamID + let selectedTeamID = explicitTeamID == nil ? await self.auth.resolvedTeamID : nil + let teamID = explicitTeamID ?? selectedTeamID let key = CloudReadRequestCoordinator.Key(path: path, accountID: identity.accountID, generation: identity.generation, teamID: teamID) let value = try await self.readRequests.read(key, deadline: deadline) { @@ -2418,14 +2459,16 @@ public actor VMClient { method, path: path, timeoutSeconds: timeoutSeconds, - expectedTeamScope: expectedTeamScope + expectedTeamScope: expectedTeamScope, + teamBinding: teamBinding ) return CloudReadRequestCoordinator.Response(data: data, http: http) } } try Task.checkCancellation() - guard await self.auth.isAuthenticatedSessionIdentityCurrent(identity), - await self.auth.resolvedTeamID == teamID else { throw CancellationError() } + guard await self.auth.isAuthenticatedSessionIdentityCurrent(identity) else { throw CancellationError() } + // A read owned by an explicit team stays valid across a selection change. + if explicitTeamID == nil, await self.auth.resolvedTeamID != teamID { throw CancellationError() } if let expectedTeamScope, !(await self.auth.isAuthenticatedTeamScopeCurrent(expectedTeamScope)) { throw CancellationError() } @@ -2453,7 +2496,8 @@ public actor VMClient { extraHeaders: [String: String] = [:], timeoutSeconds: TimeInterval? = nil, retryTransientServiceUnavailable: Bool = false, - allowedUnderManagedPolicy: Bool = false, expectedTeamScope: AuthenticatedTeamScope? = nil + allowedUnderManagedPolicy: Bool = false, expectedTeamScope: AuthenticatedTeamScope? = nil, + teamBinding: VMRequestTeamBinding = .selected ) async throws -> (Data, HTTPURLResponse) { try checkCloudAccess(allowedUnderManagedPolicy: allowedUnderManagedPolicy) let minted = VMRequestTraceContext.mint() @@ -2493,6 +2537,7 @@ public actor VMClient { timeoutSeconds: timeoutSeconds, retryTransientServiceUnavailable: retryTransientServiceUnavailable, allowedWhenCloudDisabled: allowedUnderManagedPolicy, expectedTeamScope: expectedTeamScope, + teamBinding: teamBinding, onRetry: { retryCount += 1 } ) record(.response( @@ -2566,6 +2611,7 @@ public actor VMClient { timeoutSeconds: TimeInterval?, retryTransientServiceUnavailable: Bool, allowedWhenCloudDisabled: Bool, expectedTeamScope: AuthenticatedTeamScope?, + teamBinding: VMRequestTeamBinding, onRetry: () -> Void ) async throws -> (Data, HTTPURLResponse) { // Bind every control-plane request to the currently published auth @@ -2575,7 +2621,13 @@ public actor VMClient { let sessionIdentity = await auth.authenticatedSessionIdentity let isAuthenticated = await auth.isAuthenticated let isRestoringSession = await auth.isRestoringSession - let requestedTeamID = await auth.resolvedTeamID + let explicitTeamID = teamBinding.explicitTeamID + let selectedTeamID = explicitTeamID == nil ? await auth.resolvedTeamID : nil + let requestedTeamID = explicitTeamID ?? selectedTeamID + // Only a request bound to the selected team is cancelled when the + // selection changes; the server authorizes an explicit owning team or + // a user-scoped request on its own membership check. + let followsSelectedTeam = teamBinding.followsSelectedTeam guard isAuthenticated || isRestoringSession else { throw VMClientError.notSignedIn } @@ -2622,8 +2674,10 @@ public actor VMClient { while true { try Task.checkCancellation() if let expectedTeamScope, !(await auth.isAuthenticatedTeamScopeCurrent(expectedTeamScope)) { throw VMClientError.notSignedIn } - guard await auth.resolvedTeamID == requestedTeamID else { - throw VMClientError.notSignedIn + if followsSelectedTeam { + guard await auth.resolvedTeamID == requestedTeamID else { + throw VMClientError.notSignedIn + } } if !allowedWhenCloudDisabled, !isCloudEnabled() { throw VMClientError.cloudMachinesDisabled } let data: Data @@ -2708,7 +2762,7 @@ public actor VMClient { throw VMClientError.notSignedIn } } - if let requestedTeamID { + if followsSelectedTeam, let requestedTeamID { guard await auth.resolvedTeamID == requestedTeamID else { throw VMClientError.notSignedIn } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMRequestTeamBinding.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMRequestTeamBinding.swift new file mode 100644 index 000000000000..7e1ea38aeefb --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMRequestTeamBinding.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Which team a control-plane request is authorized against. +/// +/// A signed-in user can hold open Cloud surfaces from several teams at once. +/// Each request names the team that owns its target; the server verifies the +/// user's membership in that team per request. +enum VMRequestTeamBinding: Sendable, Equatable { + /// The currently selected team. The request fails if the selection + /// changes before it completes, so an old team's result is never published + /// into the new team's views. + case selected + /// The team that owns the target resource, captured when the surface was + /// created. A selection change does not cancel the request. + case owner(String) + /// A user-scoped request (tunnel enrollment). The selected team header is + /// still sent for older servers, but a selection change does not cancel it. + case user + + /// Normalizes an optional explicit team: nil or blank means ``selected``. + init(explicitTeamID: String?) { + let trimmed = explicitTeamID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + self = .owner(trimmed) + } else { + self = .selected + } + } + + /// The owning team for ``owner(_:)``; nil otherwise. + var explicitTeamID: String? { + if case .owner(let teamID) = self { return teamID } + return nil + } + + /// Whether a change of the selected team cancels the request. + var followsSelectedTeam: Bool { self == .selected } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineAccessLossTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineAccessLossTests.swift new file mode 100644 index 000000000000..ca044d7b3bd6 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineAccessLossTests.swift @@ -0,0 +1,32 @@ +import CmuxCloud +import Foundation +import Testing + +@Suite("Cloud machine access loss") +struct CloudMachineAccessLossTests { + @Test("404 vm_not_found, 403 and vm_owner_mismatch are permanent") + func permanentAnswers() { + #expect(CloudMachineAccessLoss(error: VMClientError.httpStatus(404, #"{"error":"vm_not_found"}"#)) == .notFound) + #expect(CloudMachineAccessLoss(error: VMClientError.httpStatus(403, #"{"error":"forbidden"}"#)) == .forbidden) + #expect(CloudMachineAccessLoss(error: VMClientError.httpStatus(403, "")) == .forbidden) + #expect(CloudMachineAccessLoss(error: VMClientError.httpStatus(409, #"{"error":"vm_owner_mismatch"}"#)) == .ownerMismatch) + } + + @Test("Transient answers keep automatic reconnects", arguments: [ + VMClientError.httpStatus(404, #"{"error":"route_not_found"}"#), + VMClientError.httpStatus(404, "not json"), + VMClientError.httpStatus(429, #"{"error":"rate_limited"}"#), + VMClientError.httpStatus(503, #"{"error":"vm_unavailable"}"#), + VMClientError.notSignedIn, + VMClientError.backendUnreachable(url: "http://127.0.0.1:1", detail: "offline"), + ]) + func transientAnswers(error: VMClientError) { + #expect(CloudMachineAccessLoss(error: error) == nil) + } + + @Test("Non-client errors are never permanent") + func otherErrors() { + #expect(CloudMachineAccessLoss(error: URLError(.timedOut)) == nil) + #expect(CloudMachineAccessLoss(error: CancellationError()) == nil) + } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SurfaceOwnershipPolicyCrossTeamTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SurfaceOwnershipPolicyCrossTeamTests.swift new file mode 100644 index 000000000000..ec3bd1c66cd5 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SurfaceOwnershipPolicyCrossTeamTests.swift @@ -0,0 +1,37 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation +import Testing + +/// Surfaces from several teams can be open at once. Team is never an input to +/// the drop rule: a Cloud workspace belongs to one machine, and a machine +/// belongs to one team, so the machine check already excludes every other team. +@Suite("Surface ownership across teams") +struct SurfaceOwnershipPolicyCrossTeamTests { + private let teamAMachine = SurfaceMachineID.cloud("vm-team-a") + private let teamBMachine = SurfaceMachineID.cloud("vm-team-b") + + @Test("A Cloud workspace accepts only its own machine, never another team's") + func cloudWorkspaceRejectsAnotherTeam() { + let teamAWorkspace = SurfaceOwnershipPolicy(cloudMachine: teamAMachine) + #expect(teamAWorkspace.rejection(for: teamAMachine) == nil) + #expect(teamAWorkspace.rejection(for: teamBMachine) == .cloudMachineMismatch) + #expect(teamAWorkspace.rejection(for: .local) == .cloudMachineMismatch) + #expect(teamAWorkspace.rejection(for: [teamAMachine, teamBMachine]) == .cloudMachineMismatch) + let teamBTerminal = SurfaceResourceID(machine: teamBMachine, kind: .terminal, key: "term_b") + #expect(teamAWorkspace.rejection(for: [teamBTerminal]) == .cloudMachineMismatch) + } + + @Test("A local workspace accepts surfaces from every team") + func localWorkspaceAcceptsEveryTeam() { + let local = SurfaceOwnershipPolicy(cloudMachine: nil) + #expect(local.rejection(for: teamAMachine) == nil) + #expect(local.rejection(for: teamBMachine) == nil) + #expect(local.rejection(for: [teamAMachine, teamBMachine, .local]) == nil) + let resources = [ + SurfaceResourceID(machine: teamAMachine, kind: .terminal, key: "term_a"), + SurfaceResourceID(machine: teamBMachine, kind: .display, key: "display"), + ] + #expect(local.rejection(for: resources) == nil) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7c4505f12ed8..3f64ea3a7e78 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -581269,6 +581269,242 @@ } } } + }, + "cloud.overlay.accessLost.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You no longer have access to this machine." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يعد لديك حق الوصول إلى هذا الجهاز." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie haben keinen Zugriff mehr auf diese Maschine." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya no tienes acceso a esta máquina." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous n’avez plus accès à cette machine." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このマシンへのアクセス権がなくなりました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 이상 이 머신에 접근할 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你已无法再访问这台机器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你已無法再存取這台機器。" + } + } + } + }, + "cloud.overlay.accessLost.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud machine unavailable" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جهاز Cloud غير متاح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Maschine nicht verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Máquina de Cloud no disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Machine Cloud indisponible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud マシンを利用できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 머신을 사용할 수 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 机器不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 機器無法使用" + } + } + } + }, + "cloud.overlay.signedOut.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to cmux to reconnect this terminal." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجّل الدخول إلى cmux لإعادة توصيل هذه الطرفية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Melden Sie sich bei cmux an, um dieses Terminal erneut zu verbinden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia sesión en cmux para volver a conectar este terminal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez-vous à cmux pour reconnecter ce terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターミナルに再接続するには cmux にサインインしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 터미널을 다시 연결하려면 cmux에 로그인하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录 cmux 以重新连接此终端。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入 cmux 以重新連線此終端機。" + } + } + } + }, + "cloud.overlay.signedOut.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session ended" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت جلسة Cloud" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung beendet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud finalizada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud terminée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションが終了しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션이 종료되었습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话已结束" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段已結束" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate+TeamScope.swift b/Sources/AppDelegate+TeamScope.swift index f749511702a2..115b29e58ee0 100644 --- a/Sources/AppDelegate+TeamScope.swift +++ b/Sources/AppDelegate+TeamScope.swift @@ -2,13 +2,27 @@ import CmuxCloud import AppKit extension AppDelegate { - /// Closes local Cloud projections before a team switch so an old team's - /// terminals, browser URLs, and reconnect configuration cannot leak into - /// the newly-selected team. + /// Prepares local Cloud state before the selected team changes. + /// + /// - Parameter isSameAccount: True when the same account selects another + /// team. Open Cloud terminals and browsers are owned by the team that + /// created them and name that team on every request, so they stay open, + /// connected, and interactive; only work that would land in the previous + /// selection (pending creates and actions) is cancelled. False when the + /// account itself changed: every Cloud projection closes so one account's + /// terminals, browser URLs, and reconnect configuration cannot leak into + /// another account. @MainActor - func prepareCloudVMAccessForTeamSwitch() { + func prepareCloudVMAccessForTeamSwitch(isSameAccount: Bool) { SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.cancelAll() CloudVMActionLauncher.shared.cancelAllForAuthTransition() + cloudWorkspaceOperationController?.cancelAll() + if isSameAccount { + // A create that already produced a machine keeps it in the team + // that owns it; the person sees it again from that team. + MachineCreateCoordinator.shared.cancelAllForAuthTransition(cleanupCreatedMachines: false) + return + } let detail = String( localized: "machines.teamSwitch.disconnectedDetail", defaultValue: "Cloud VM access moved to another team." @@ -33,12 +47,7 @@ extension AppDelegate { } } ClosedItemHistoryStore.shared.removeManagedCloudVMRecords() - cloudWorkspaceOperationController?.cancelAll() cloudTunnelAccessDidEnd() - NotificationCenter.default.post( - name: .cmuxCloudVMAccessDidEnd, - object: self, - userInfo: ["cmux.teamSwitch": true] - ) + NotificationCenter.default.post(name: .cmuxCloudVMAccessDidEnd, object: self) } } diff --git a/Sources/Auth/MacAuthComposition.swift b/Sources/Auth/MacAuthComposition.swift index e9a2e2e80518..49d6d3578cce 100644 --- a/Sources/Auth/MacAuthComposition.swift +++ b/Sources/Auth/MacAuthComposition.swift @@ -220,8 +220,8 @@ struct MacAuthComposition { browserSignIn: browserSignIn ) self.teamScopeRecoveryTriggers = MacAuthTeamScopeRecoveryTriggers(coordinator: coordinator) - self.cloudTeamScopeObserver = CloudTeamScopeObserver(auth: coordinator) { - AppDelegate.shared?.prepareCloudVMAccessForTeamSwitch() + self.cloudTeamScopeObserver = CloudTeamScopeObserver(auth: coordinator) { isSameAccount in + AppDelegate.shared?.prepareCloudVMAccessForTeamSwitch(isSameAccount: isSameAccount) } } diff --git a/Sources/Cloud/CloudTeamScopeObserver.swift b/Sources/Cloud/CloudTeamScopeObserver.swift index 7340a5235b54..a7d58e84d9d2 100644 --- a/Sources/Cloud/CloudTeamScopeObserver.swift +++ b/Sources/Cloud/CloudTeamScopeObserver.swift @@ -10,11 +10,16 @@ extension Notification.Name { /// Reconciles local Cloud transports whenever the authenticated team changes. /// The auth coordinator is the source of truth; this observer only coordinates /// teardown and rediscovery at the app boundary. +/// +/// Sign-out and account changes tear every Cloud transport down. A team change +/// within one account only moves discovery to the new team: open Cloud surfaces +/// of every team the user belongs to stay connected, because each request names +/// the surface's owning team and the server verifies membership per request. @MainActor final class CloudTeamScopeObserver { private let auth: AuthCoordinator private let registry: CmuxTuiSurfaceProviderRegistry - private let onTeamWillChange: @MainActor () -> Void + private let onTeamWillChange: @MainActor (_ isSameAccount: Bool) -> Void private var observationTask: Task? /// Registry teardown can wait for remote transports. Keep it out of the /// auth scope stream so a slow provider never prevents the next team @@ -24,12 +29,13 @@ final class CloudTeamScopeObserver { private var desiredScope: AuthenticatedTeamScope? private var desiredGeneration: UInt64 = 0 private var needsTeardown = false + private var needsTeamRescope = false private var needsResume = false init( auth: AuthCoordinator, registry: CmuxTuiSurfaceProviderRegistry? = nil, - onTeamWillChange: @escaping @MainActor () -> Void + onTeamWillChange: @escaping @MainActor (_ isSameAccount: Bool) -> Void ) { self.auth = auth self.registry = registry ?? .shared @@ -48,15 +54,18 @@ final class CloudTeamScopeObserver { guard !Task.isCancelled else { return } guard scope != previousScope else { continue } let changedTeams = previousScope != nil + let isSameAccount = changedTeams && scope != nil + && previousScope?.session.accountID == scope?.session.accountID previousScope = scope if changedTeams { - self.onTeamWillChange() + self.onTeamWillChange(isSameAccount) NotificationCenter.default.post(name: .cmuxCloudTeamScopeDidChange, object: self) } self.requestReconciliation( scope: scope, - requiresTeardown: scope == nil || changedTeams, + requiresTeardown: scope == nil || (changedTeams && !isSameAccount), + requiresTeamRescope: isSameAccount, requiresResume: scope != nil ) } @@ -66,6 +75,7 @@ final class CloudTeamScopeObserver { private func requestReconciliation( scope: AuthenticatedTeamScope?, requiresTeardown: Bool, + requiresTeamRescope: Bool, requiresResume: Bool ) { desiredScope = scope @@ -73,6 +83,9 @@ final class CloudTeamScopeObserver { if requiresTeardown { needsTeardown = true } + if requiresTeamRescope { + needsTeamRescope = true + } needsResume = requiresResume guard reconciliationTask == nil else { return } reconciliationTask = Task { @MainActor [weak self] in @@ -91,6 +104,8 @@ final class CloudTeamScopeObserver { // transports. The same teardown is sufficient for that scope; // the generation check below will move directly to resume. needsTeardown = false + // A full teardown already dropped every team's providers. + needsTeamRescope = false } guard generation == desiredGeneration else { continue } guard let scope else { @@ -98,6 +113,14 @@ final class CloudTeamScopeObserver { return } guard !Task.isCancelled, auth.isAuthenticatedTeamScopeCurrent(scope) else { return } + if needsTeamRescope, !registry.isRetired { + // Same account, new team: keep every open surface and its + // transport; only discovery moves to the selected team. + needsTeamRescope = false + needsResume = false + await registry.teamScopeDidChange() + } + needsTeamRescope = false if needsResume { needsResume = false await registry.resumeAfterSignIn() diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index a6b47039e1f1..ba6ddb68c88b 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -441,11 +441,18 @@ final class CloudTuiManualMirrorSession { guard (!geometryClaimed && !claimUnsupported) || geometryClaimBlockedByPeer else { return } claimGeometry() } + /// Why this attachment stopped; nil until ``stop(reason:)`` runs. + private(set) var stopReason: CloudTuiManualMirrorStopReason? /// Permanently tears down this view's attachment without closing the remote /// terminal. Closing the control socket is the cleanup fence for old /// servers; newer servers additionally retire the lease with the same close. - func stop() { + /// + /// - Parameter reason: Why the attachment ends. Unless the pane is closing, + /// the pane keeps a card for `reason`, so a stop never leaves a silent + /// frozen frame that drops input. + func stop(reason: CloudTuiManualMirrorStopReason = .paneClosed) { guard phase != .stopped else { return } + stopReason = reason let wasAttached = phase == .attached transition(to: .stopped) endPresentationEpisode() @@ -477,7 +484,8 @@ final class CloudTuiManualMirrorSession { connection = nil pendingRequests.removeAll(keepingCapacity: false) if let surface, surface.hostedView.cloudTerminalOverlay.session === self { - surface.hostedView.cloudTerminalOverlay.unbindSession(self) + surface.hostedView.cloudTerminalOverlay.endSession(self, presentation: reason.endedPresentation) + surface.hostedView.synchronizeCloudTerminalReconnectOverlay() surface.onManualSizeApplied = nil surface.onRuntimeReady = nil surface.onManualWindowAttached = nil diff --git a/Sources/Cloud/CloudTuiManualMirrorStopReason.swift b/Sources/Cloud/CloudTuiManualMirrorStopReason.swift new file mode 100644 index 000000000000..486fe8bcfc34 --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorStopReason.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Why a Cloud terminal attachment stopped for good. +/// +/// Every stop ends in a visible state: either the local pane is going away, +/// or the pane keeps a card that says what happened. A stop never leaves a +/// silent frozen frame that drops input. +enum CloudTuiManualMirrorStopReason: Equatable, Sendable { + /// The local pane closed or is being replaced; nothing remains to present. + case paneClosed + /// The control plane answered that the user can no longer reach the + /// machine (404 `vm_not_found`, 403, `vm_owner_mismatch`), or the machine + /// left the user's machine list. + case accessLost + /// The account signed out; Cloud access ends for every team. + case signedOut + /// Cloud Machines were disabled or suspended while the pane stayed open. + case cloudUnavailable + + /// The card a pane keeps after the stop; nil when the pane is closing. + var endedPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { + switch self { + case .paneClosed: + return nil + case .accessLost: + return .init( + title: String(localized: "cloud.overlay.accessLost.title", defaultValue: "Cloud machine unavailable"), + detail: String( + localized: "cloud.overlay.accessLost.detail", + defaultValue: "You no longer have access to this machine." + ), + showsProgress: false, + showsReconnectButton: false + ) + case .signedOut: + return .init( + title: String(localized: "cloud.overlay.signedOut.title", defaultValue: "Cloud session ended"), + detail: String( + localized: "cloud.overlay.signedOut.detail", + defaultValue: "Sign in to cmux to reconnect this terminal." + ), + showsProgress: false, + showsReconnectButton: false + ) + case .cloudUnavailable: + return .init( + title: String(localized: "cloud.overlay.error.title", defaultValue: "Cloud session unavailable"), + detail: String( + localized: "cloud.feature.disabled", + defaultValue: "Cloud Machines are temporarily unavailable." + ), + showsProgress: false, + showsReconnectButton: false + ) + } + } +} diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index 999e6e896141..6a27a0d6fca5 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -154,7 +154,14 @@ final class MachinesPanelViewModel: ObservableObject { wakeNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter, lifecycleNotificationCenter: NotificationCenter = .default, isCloudEnabled: @escaping @MainActor () -> Bool = { CloudMachinesFeature.isEnabled }, - catalogProvider: @escaping @MainActor () -> SurfaceCatalogSnapshot = { SurfaceCatalog.shared.snapshot }, + // Another team's machines stay in the catalog while open surfaces use + // them; the sidebar lists only the selected team's fleet. + catalogProvider: @escaping @MainActor () -> SurfaceCatalogSnapshot = { + MachinesPanelViewModel.catalog( + SurfaceCatalog.shared.snapshot, + hiding: CmuxTuiSurfaceProviderRegistry.shared.foreignTeamMachineIDs + ) + }, localWorkspacesProvider: (@MainActor () -> [CloudTreeLocalWorkspace])? = nil ) { let networkClient = client ?? VMClient.shared diff --git a/Sources/CloudTerminalOverlayCoordinator.swift b/Sources/CloudTerminalOverlayCoordinator.swift index b81a9c970a00..4a5163b51efe 100644 --- a/Sources/CloudTerminalOverlayCoordinator.swift +++ b/Sources/CloudTerminalOverlayCoordinator.swift @@ -14,7 +14,13 @@ private let cloudTerminalPresentationLogger = Logger( @MainActor final class CloudTerminalOverlayCoordinator { private let dismissalStore: CloudBannerDismissalStore - weak var session: CloudTuiManualMirrorSession? + weak var session: CloudTuiManualMirrorSession? { + didSet { if session != nil { endedPresentation = nil } } + } + /// The card a stopped session left behind (access lost, signed out). It + /// outlives the session so the pane never falls back to a frozen frame; + /// binding a new session replaces it. + private(set) var endedPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? private(set) var overlay: CloudTerminalReconnectOverlayView? private weak var anchor: GhosttyTerminalView.HostContainerView? private var anchorOwnership: (generation: UInt64, serial: UInt64)? @@ -76,6 +82,8 @@ final class CloudTerminalOverlayCoordinator { let presentation: CloudTerminalReconnectOverlayPolicy.Presentation? if let session { presentation = session.connectionPresentation + } else if let endedPresentation { + presentation = endedPresentation } else { presentation = legacyPresentation } @@ -124,6 +132,21 @@ final class CloudTerminalOverlayCoordinator { lastDestination = next } + /// Retires `expectedSession`, keeping `presentation` as the pane's final + /// card. A nil presentation removes the card, like ``unbindSession(_:)``. + func endSession( + _ expectedSession: CloudTuiManualMirrorSession, + presentation: CloudTerminalReconnectOverlayPolicy.Presentation? + ) { + guard session === expectedSession else { return } + session = nil + endedPresentation = presentation + if presentation == nil { + overlay?.removeFromSuperview() + overlay = nil + } + } + /// A retired session cannot clear a replacement session's presentation. func unbindSession(_ expectedSession: CloudTuiManualMirrorSession) { guard session === expectedSession else { return } diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index f6a6f2274b54..e7fbfc349353 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -388,7 +388,8 @@ extension DockSplitStore { forwardHistoryURLStrings: history.forwardHistoryURLStrings, transparentBackground: browser.sessionSnapshotTransparentBackground, diffViewerToken: diffViewer?.token, - diffViewerRequestPath: diffViewer?.requestPath, cloudResource: browser.cloudResourceForSession + diffViewerRequestPath: diffViewer?.requestPath, cloudResource: browser.cloudResourceForSession, + cloudTeamID: browser.cloudTeamIDForSession ) } else if let deferred = panel as? DeferredBrowserPanel { browserSnapshot = deferred.sessionPanelSnapshot.browser diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 055268a82a7e..a02be9b01539 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -11131,7 +11131,8 @@ final class GhosttySurfaceScrollView: NSView { func synchronizeCloudTerminalReconnectOverlay() { let legacyPresentation = cloudTerminalOverlay.session == nil ? currentCloudTerminalReconnectPresentation() : nil - guard cloudTerminalOverlay.session != nil || legacyPresentation != nil || cloudTerminalOverlay.overlay != nil else { return } + guard cloudTerminalOverlay.session != nil || cloudTerminalOverlay.endedPresentation != nil + || legacyPresentation != nil || cloudTerminalOverlay.overlay != nil else { return } cloudTerminalOverlay.synchronize( hostedView: self, contentFrame: sessionContentFrame, diff --git a/Sources/Panels/BrowserPanel+CloudConnection.swift b/Sources/Panels/BrowserPanel+CloudConnection.swift index 96d4d2b535c1..fdfc0810fa97 100644 --- a/Sources/Panels/BrowserPanel+CloudConnection.swift +++ b/Sources/Panels/BrowserPanel+CloudConnection.swift @@ -62,6 +62,16 @@ extension BrowserPanel { return cloudResourceForDuplication } + /// The owning team persisted with ``cloudResourceForSession``: the + /// machine's provider team, else the restored team, else the selected team. + var cloudTeamIDForSession: String? { + guard let machineID = cloudResourceForSession?.machine.cloudMachineID else { return nil } + if let owner = CmuxTuiSurfaceProviderRegistry.shared.ownerTeamID(forMachineID: machineID) { + return owner + } + return restoredCloudTeamID ?? WorkspaceCloudVMBinding.owningTeamID(forVMID: machineID, previous: nil) + } + /// Restore by stable resource identity before loading any saved address. /// A stale/unknown provider leaves an owned placeholder, never a local page. func restoreCloudResource(_ resource: SurfaceResourceID, preferredURL: URL? = nil, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9e367f3605ab..e215b8731a45 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2076,6 +2076,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Saved Cloud path waiting for a provider/resource to become available. /// It is consumed after the first successful authenticated configuration. var pendingCloudRestoreURL: URL? + /// The team that owns this pane's Cloud machine, restored from a snapshot. + /// Kept so a pane of another team re-persists its own team, not the selection. + var restoredCloudTeamID: String? /// The workspace ID this panel belongs to private(set) var workspaceId: UUID @@ -4656,6 +4659,10 @@ final class BrowserPanel: Panel, ObservableObject { currentURL = restoredURL if let resource = snapshot.cloudResource { + restoredCloudTeamID = snapshot.cloudTeamID + if let machineID = resource.machine.cloudMachineID { + CmuxTuiSurfaceProviderRegistry.shared.adoptOwnerTeam(snapshot.cloudTeamID, forMachineID: machineID) + } restoreCloudResource(resource, preferredURL: restoredURL, activate: shouldRenderRestoredWebView) if !shouldRenderRestoredWebView { shouldRenderWebView = false; refreshNavigationAvailability() } return diff --git a/Sources/RemoteTui/WorkspaceCloudVMBinding+OwningTeam.swift b/Sources/RemoteTui/WorkspaceCloudVMBinding+OwningTeam.swift new file mode 100644 index 000000000000..1a50b858d212 --- /dev/null +++ b/Sources/RemoteTui/WorkspaceCloudVMBinding+OwningTeam.swift @@ -0,0 +1,31 @@ +import Foundation + +extension WorkspaceCloudVMBinding { + /// The team that owns `vmID`: the registry's owning team (a provider, or a + /// team persisted with a restored pane), else the previous binding's team + /// for the same machine, else the selected team. + /// + /// SSH machines have no team and resolve to nil. + @MainActor + static func owningTeamID(forVMID vmID: String, previous: WorkspaceCloudVMBinding?) -> String? { + guard !vmID.hasPrefix("ssh:") else { return nil } + if let owner = CmuxTuiSurfaceProviderRegistry.shared.ownerTeamID(forMachineID: vmID) { + return owner + } + if let previous, previous.vmID == vmID, let team = previous.teamID { + return team + } + return AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope?.teamID + } + + /// This binding with a team, adopting ``owningTeamID(forVMID:previous:)`` + /// when a legacy snapshot restored it without one. + @MainActor + func adoptingOwningTeam() -> WorkspaceCloudVMBinding { + guard teamID == nil else { return self } + return WorkspaceCloudVMBinding( + vmID: vmID, isBase: isBase, remoteWorkspaceID: remoteWorkspaceID, + teamID: Self.owningTeamID(forVMID: vmID, previous: nil) + ) + } +} diff --git a/Sources/RemoteTui/WorkspaceCloudVMBinding.swift b/Sources/RemoteTui/WorkspaceCloudVMBinding.swift index 9663391c35cc..860e04252e6c 100644 --- a/Sources/RemoteTui/WorkspaceCloudVMBinding.swift +++ b/Sources/RemoteTui/WorkspaceCloudVMBinding.swift @@ -10,11 +10,16 @@ struct WorkspaceCloudVMBinding: Equatable, Sendable { /// recorded when a remote workspace is opened locally. Local workspace renames /// write through to it (`CloudWorkspaceRenameService`). let remoteWorkspaceID: String? + /// The team that owns the machine, captured when the workspace was bound. + /// Nil for SSH machines and for legacy bindings not yet re-persisted. + let teamID: String? - init(vmID: String, isBase: Bool, remoteWorkspaceID: String? = nil) { + init(vmID: String, isBase: Bool, remoteWorkspaceID: String? = nil, teamID: String? = nil) { self.vmID = vmID self.isBase = isBase self.remoteWorkspaceID = remoteWorkspaceID + let trimmedTeam = teamID?.trimmingCharacters(in: .whitespacesAndNewlines) + self.teamID = trimmedTeam?.isEmpty == false ? trimmedTeam : nil } /// Machine ids are provider handles (`vivid-newt`, `sc-…`): letters, digits, `.`, `_`, `-`. diff --git a/Sources/SessionBrowserPanelSnapshot.swift b/Sources/SessionBrowserPanelSnapshot.swift index 6102985da3c0..b71f2cc0697d 100644 --- a/Sources/SessionBrowserPanelSnapshot.swift +++ b/Sources/SessionBrowserPanelSnapshot.swift @@ -23,6 +23,9 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { var diffViewerRequestPath: String? = nil /// Per-panel provenance also survives Dock and closed-panel snapshots. var cloudResource: SurfaceResourceID? = nil + /// The team that owns ``cloudResource``'s machine. Absent in snapshots + /// written before multi-team Cloud; restore then adopts the selected team. + var cloudTeamID: String? = nil init( urlString: String?, @@ -38,7 +41,8 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { transparentBackground: Bool? = nil, diffViewerToken: String? = nil, diffViewerRequestPath: String? = nil, - cloudResource: SurfaceResourceID? = nil + cloudResource: SurfaceResourceID? = nil, + cloudTeamID: String? = nil ) { self.urlString = urlString self.profileID = profileID @@ -54,6 +58,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { self.diffViewerToken = diffViewerToken self.diffViewerRequestPath = diffViewerRequestPath self.cloudResource = cloudResource + self.cloudTeamID = cloudResource == nil ? nil : cloudTeamID } private enum CodingKeys: String, CodingKey { @@ -71,6 +76,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { case diffViewerToken case diffViewerRequestPath case cloudResource + case cloudTeamID } init(from decoder: Decoder) throws { @@ -89,5 +95,6 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { diffViewerToken = try container.decodeIfPresent(String.self, forKey: .diffViewerToken) diffViewerRequestPath = try container.decodeIfPresent(String.self, forKey: .diffViewerRequestPath) cloudResource = try container.decodeIfPresent(SurfaceResourceID.self, forKey: .cloudResource) + cloudTeamID = try container.decodeIfPresent(String.self, forKey: .cloudTeamID) } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 46ce349052ef..f7b5eb924869 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1720,6 +1720,9 @@ struct SessionCloudVMBindingSnapshot: Codable, Sendable, Equatable { /// The machine's cmux-tui workspace this local workspace stands for; absent in /// legacy snapshots and for machine-only bindings (`vm shell`). var remoteWorkspaceID: String? = nil + /// The team that owns the machine. Absent in snapshots written before + /// multi-team Cloud; restore then adopts the selected team. + var teamID: String? = nil } struct SessionWorkspaceSnapshot: Codable, Sendable { @@ -1771,6 +1774,10 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { /// Remote surfaces this workspace's panes projected (`SurfaceCatalog`); absent for /// workspaces that only ever showed local panes, so older manifests decode unchanged. var surfaceProjections: [SurfaceProjectionRecord]? = nil + /// The team that owns each Cloud machine this workspace shows, by machine + /// id. Restore reconnects those panes with that team even when another + /// team is selected. Absent in manifests written before multi-team Cloud. + var cloudMachineTeams: [String: String]? = nil /// Optional so manifests written before this field decode cleanly. var environment: [String: String]? = nil /// Manual task-status override raw values and the persisted checklist. Optional-with-nil-default diff --git a/Sources/SessionSnapshotImportTrust.swift b/Sources/SessionSnapshotImportTrust.swift index 0197af54908d..aba737d36a75 100644 --- a/Sources/SessionSnapshotImportTrust.swift +++ b/Sources/SessionSnapshotImportTrust.swift @@ -104,13 +104,15 @@ enum SessionSnapshotImportTrust { var workspace = window.tabManager.workspaces[workspaceIndex] if workspace.remote != nil || workspace.cloudVM != nil || workspace.environment?.isEmpty == false - || workspace.surfaceProjections?.isEmpty == false { + || workspace.surfaceProjections?.isEmpty == false + || workspace.cloudMachineTeams?.isEmpty == false { report.droppedRemoteWorkspaceCount += 1 } workspace.remote = nil workspace.cloudVM = nil workspace.environment = nil workspace.surfaceProjections = nil + workspace.cloudMachineTeams = nil workspace.panels = sanitize(workspace.panels) if var dock = workspace.dock { dock.panels = sanitize(dock.panels) @@ -281,6 +283,7 @@ enum SessionSnapshotImportTrust { sanitized.forwardHistoryURLStrings = browser.forwardHistoryURLStrings?.filter(isAllowedImportedURL) sanitized.profileID = nil sanitized.cloudResource = nil + sanitized.cloudTeamID = nil sanitized.diffViewerToken = nil sanitized.diffViewerRequestPath = nil sanitized.transparentBackground = nil @@ -290,6 +293,7 @@ enum SessionSnapshotImportTrust { || sanitized.forwardHistoryURLStrings != browser.forwardHistoryURLStrings || browser.profileID != nil || browser.cloudResource != nil + || browser.cloudTeamID != nil || browser.diffViewerToken != nil || browser.diffViewerRequestPath != nil || browser.transparentBackground != nil diff --git a/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift b/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift index a15ee1d2db8c..e2095c8e2963 100644 --- a/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift +++ b/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift @@ -100,10 +100,12 @@ extension CloudWorkspaceRenameService { workspace.cloudVMBinding = nil continue case .rebind(let targetMachine, let targetWorkspaceID): + let targetVMID = targetMachine.tuiMachineID ?? binding.vmID workspace.cloudVMBinding = WorkspaceCloudVMBinding( - vmID: targetMachine.tuiMachineID ?? binding.vmID, + vmID: targetVMID, isBase: binding.isBase, - remoteWorkspaceID: targetWorkspaceID + remoteWorkspaceID: targetWorkspaceID, + teamID: WorkspaceCloudVMBinding.owningTeamID(forVMID: targetVMID, previous: binding) ) } reconcileRemoteWorkspaceName(workspace: workspace, machine: machine, state: state, diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+Hosting.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+Hosting.swift index e7c9d1fbbdf4..c0e47f0a8491 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+Hosting.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+Hosting.swift @@ -9,6 +9,7 @@ extension CmuxTuiSurfaceProvider { convenience init( summary: VMSummary, fileAccessTeamScope: AuthenticatedTeamScope? = nil, + ownerTeamID: String? = nil, links: CloudMachineLinkManager, catalog: SurfaceCatalog, portForwards: CloudHubPortForwarder? = nil, @@ -16,12 +17,10 @@ extension CmuxTuiSurfaceProvider { portAccessStore: CloudPortAccessStore? = nil, displayCoordinator: CloudDisplayCoordinator? = nil, browserPolicy: @escaping @MainActor () -> BrowserURLAllowlistPolicy = { BrowserURLAllowlistPolicy() }, - loadPortSummary: @escaping @MainActor (String) async throws -> VMSummary = { id in - guard let client = VMClient.shared else { throw CmuxTuiSurfaceProvider.ProviderError.notSignedIn } - return try await client.status(id: id) - } + loadPortSummary: (@MainActor (String) async throws -> VMSummary)? = nil ) { - self.init(summary: .cloud(summary), fileAccessTeamScope: fileAccessTeamScope, links: links, catalog: catalog, + self.init(summary: .cloud(summary), fileAccessTeamScope: fileAccessTeamScope, ownerTeamID: ownerTeamID, + links: links, catalog: catalog, portForwards: portForwards, attachmentClock: attachmentClock, portAccessStore: portAccessStore, displayCoordinator: displayCoordinator, browserPolicy: browserPolicy, loadPortSummary: loadPortSummary) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+Lifecycle.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+Lifecycle.swift index 678786074d46..296e68b9fdec 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+Lifecycle.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+Lifecycle.swift @@ -13,8 +13,8 @@ extension CmuxTuiSurfaceProvider { } /// Retire synchronously, then join mutations before releasing shared transport access. - func stop() async { - suspendForFeatureFlag() + func stop(stopReason: CloudTuiManualMirrorStopReason = .cloudUnavailable) async { + suspendForFeatureFlag(stopReason: stopReason) await terminalMutationQueue.waitForIdle() await portAccessStore.remove(machineID: machineID) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+PortForward.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+PortForward.swift index 1ab7a3fe5c7a..5bb59213bbe2 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+PortForward.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+PortForward.swift @@ -180,7 +180,7 @@ extension CmuxTuiSurfaceProvider { // desktop is checked through the existing browser carrier below. if !self.isAwake { guard let client = VMClient.shared else { throw ProviderError.notSignedIn } - _ = try await client.openPort(id: self.machineID, port: target.port) + _ = try await client.openPort(id: self.machineID, port: target.port, teamID: self.ownerTeamID) } guard self.isCurrentLifecycleGeneration(generation), self.isRegisteredInCatalog() else { throw CancellationError() } }, @@ -220,7 +220,7 @@ extension CmuxTuiSurfaceProvider { #if DEBUG cmuxDebugLog("cloud.desktop.proxy.heal.begin machine=\(self.machineID) port=\(port)") #endif - _ = try await client.openPort(id: self.machineID, port: port) + _ = try await client.openPort(id: self.machineID, port: port, teamID: self.ownerTeamID) #if DEBUG cmuxDebugLog("cloud.desktop.proxy.heal.complete machine=\(self.machineID) port=\(port) elapsedMs=\(Int(Date().timeIntervalSince(desktopStartedAt) * 1000))") #endif @@ -282,7 +282,7 @@ extension CmuxTuiSurfaceProvider { /// Explicit provider preview API retained for diagnostic callers only. func controlPlanePreviewURL(port: Int) async throws -> URL { guard let client = VMClient.shared else { throw ProviderError.notSignedIn } - let endpoint = try await client.openPort(id: machineID, port: port) + let endpoint = try await client.openPort(id: machineID, port: port, teamID: ownerTeamID) guard let url = URL(string: endpoint.openUrl), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { throw ProviderError.invalidPreviewURL } return url } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry+Production.swift b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry+Production.swift index 3bde83e3465c..29b68291e733 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry+Production.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry+Production.swift @@ -37,6 +37,11 @@ extension CmuxTuiSurfaceProviderRegistry { guard let client = VMClient.shared else { return nil } return try? await client.listPage() }, + activeTeamID: { AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope?.teamID }, + loadMachineStatus: { machineID, teamID in + guard let client = VMClient.shared else { throw VMClientError.notSignedIn } + return try await client.status(id: machineID, teamID: teamID) + }, hasCloudSession: { AppDelegate.shared?.auth?.accountFlow.isAuthenticated == true } ) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift index 04ece22111a3..6c52a86fd751 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift @@ -38,6 +38,18 @@ final class CmuxTuiSurfaceProviderRegistry { let isCloudEnabled: @MainActor () -> Bool private let allowsBackgroundWork: @MainActor () -> Bool private let listPage: @MainActor () async -> VMListPage? + /// The selected team, whose fleet ``listPage`` reads. New providers are + /// owned by it; providers owned by another team are retained only while + /// they back an open surface, and are never pruned by this team's page. + private let activeTeamID: @MainActor () -> String? + /// Reads one machine with its owning team (`GET /api/vm/` with that + /// team's header). Machines of a team other than the selected one are not + /// on the fleet page, so restored and open panes of that team are found + /// and kept current through this per-machine read. + private let loadMachineStatus: @MainActor (_ machineID: String, _ teamID: String) async throws -> VMSummary + /// Owning teams persisted with restored panes and workspaces, by machine + /// id. A registered provider's own team takes precedence. + private var adoptedOwnerTeams: [String: String] = [:] /// Whether an account is signed in. Activation prepares the carrier before /// the fleet read only for a signed-in account; a signed-out Mac must not /// enroll or start a hub from a config a previous account left on disk. @@ -86,6 +98,10 @@ final class CmuxTuiSurfaceProviderRegistry { isCloudEnabled: @escaping @MainActor () -> Bool = { true }, allowsBackgroundWork: @escaping @MainActor () -> Bool = { true }, listPage: @escaping @MainActor () async -> VMListPage? = { nil }, + activeTeamID: @escaping @MainActor () -> String? = { nil }, + loadMachineStatus: @escaping @MainActor (_ machineID: String, _ teamID: String) async throws -> VMSummary = { _, _ in + throw CancellationError() + }, hasCloudSession: @escaping @MainActor () -> Bool = { true }, refreshProvider: @escaping @MainActor (CmuxTuiSurfaceProvider, Bool) async -> Bool = { provider, force in await provider.refreshCurrentGraph(force: force) @@ -98,6 +114,8 @@ final class CmuxTuiSurfaceProviderRegistry { self.isCloudEnabled = isCloudEnabled self.allowsBackgroundWork = allowsBackgroundWork self.listPage = listPage + self.activeTeamID = activeTeamID + self.loadMachineStatus = loadMachineStatus self.hasCloudSession = hasCloudSession self.refreshProvider = refreshProvider self.notificationCenter = notificationCenter @@ -138,7 +156,9 @@ final class CmuxTuiSurfaceProviderRegistry { let addresses = [summary.addressIPv4, summary.addressIPv6].compactMap { $0 } guard !addresses.isEmpty, machineTeardowns[registeredMachineID(matching: summary.id)] == nil else { return } let generation = refreshGeneration + let ownerTeamID = activeTeamID() await links.setPrivateAddresses(addresses, for: summary.id) + await links.setOwnerTeam(ownerTeamID, for: summary.id) if summary.cmuxTuiContract == Self.trustedCarrierContract { await links.markTrustedCarrier(machineID: summary.id) } @@ -147,7 +167,8 @@ final class CmuxTuiSurfaceProviderRegistry { guard !isRetired, generation == refreshGeneration, scope == creationScope, providers[summary.id] == nil else { return } let provider = CmuxTuiSurfaceProvider( - summary: summary, fileAccessTeamScope: AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope, links: links, catalog: catalog, + summary: summary, fileAccessTeamScope: AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope, + ownerTeamID: ownerTeamID, links: links, catalog: catalog, portForwards: portForwards, portAccessStore: portAccess ) providers[summary.id] = provider @@ -217,18 +238,10 @@ final class CmuxTuiSurfaceProviderRegistry { forName: .cmuxCloudVMAccessDidEnd, object: nil, queue: .main - ) { [weak self] notification in + ) { [weak self] _ in MainActor.assumeIsolated { guard let self, self.accessEpoch == epoch else { return } self.creationEpoch = UUID() - if notification.userInfo?["cmux.teamSwitch"] as? Bool == true { - // Team switches synchronously revoke the old scope. The - // scope observer will later await full transport teardown, - // but no old discovery or provider refresh may publish in - // the interval before that await completes. - self.invalidateAccess() - return - } Task { @MainActor in await self.accessDidEnd(epoch: epoch) } } } @@ -343,9 +356,19 @@ final class CmuxTuiSurfaceProviderRegistry { guard let self, access == self.accessEpoch, !Task.isCancelled, let discovered = await self.discoverMachines(force: force, updateExisting: true), access == self.accessEpoch, !Task.isCancelled else { return false } - let activeMachines = (force || !self.hasCompletedInitialRefresh) ? Set(discovered.map(\.machine)) : (self.catalog?.projectedMachines ?? []).union(self.catalog?.pendingRestoredMachineIDs.map(SurfaceMachineID.cloud) ?? []).union(self.pendingMachineCreationIDs.map(SurfaceMachineID.cloud)).union(Set(discovered.filter { !self.refreshedMachineIDs.contains($0.machine) || $0.info.linkState != .connected || $0.cloudState?.cursor == nil }.map(\.machine))) + // Another team's machines behind open surfaces are not on the + // selected team's page; read them one by one with their own + // team, then refresh them so a revoked membership surfaces as + // a card, not a freeze. + await self.refreshForeignOwnedMachines() + guard access == self.accessEpoch, !Task.isCancelled else { return false } + let foreign = self.retainedForeignTeamMachineIDs(activeTeamID: self.activeTeamID()) + .subtracting(discovered.map(\.machineID)) + .compactMap { self.providers[$0] } + let candidates = discovered + foreign + let activeMachines = (force || !self.hasCompletedInitialRefresh) ? Set(candidates.map(\.machine)) : (self.catalog?.projectedMachines ?? []).union(self.catalog?.pendingRestoredMachineIDs.map(SurfaceMachineID.cloud) ?? []).union(self.pendingMachineCreationIDs.map(SurfaceMachineID.cloud)).union(Set(discovered.filter { !self.refreshedMachineIDs.contains($0.machine) || $0.info.linkState != .connected || $0.cloudState?.cursor == nil }.map(\.machine))) await withTaskGroup(of: Void.self) { group in - for provider in discovered where activeMachines.contains(provider.machine) { + for provider in candidates where activeMachines.contains(provider.machine) { group.addTask { @MainActor in guard access == self.accessEpoch, !Task.isCancelled else { return } let succeeded = await self.refreshProvider(provider, force); if succeeded, provider.isRegisteredInCatalog() { self.refreshedMachineIDs.insert(provider.machine) } @@ -399,6 +422,141 @@ final class CmuxTuiSurfaceProviderRegistry { providers[machineID] } + /// Machines owned by a team other than the selected one. They stay + /// registered while open surfaces use them but are left out of the + /// selected team's Cloud sidebar. + var foreignTeamMachineIDs: Set { + let active = activeTeamID() + return Set(providers.compactMap { id, provider in + guard let owner = provider.ownerTeamID, owner != active else { return nil } + return id + }) + } + + /// Another team's machines that still back an open surface (a projected + /// pane, a restoring pane, or a Cloud workspace bound to the machine). + private func retainedForeignTeamMachineIDs(activeTeamID active: String?) -> Set { + guard let catalog else { return [] } + let projected = catalog.projectedMachines.union(catalog.pendingRestoredMachineIDs.map(SurfaceMachineID.cloud)) + return Set(providers.compactMap { id, provider in + guard let owner = provider.ownerTeamID, owner != active, + !provider.hasLostAccess, projected.contains(.cloud(id)) else { return nil } + return id + }) + } + + /// Restored panes of another team whose provider is not registered yet. + /// The selected team's page must not prune them before their own read. + private func pendingForeignRestoredMachineIDs(activeTeamID active: String?) -> Set { + guard let catalog else { return [] } + return catalog.pendingRestoredMachineIDs.filter { id in + providers[id] == nil && adoptedOwnerTeams[id].map { $0 != active } == true + } + } + + /// The team that owns `machineID`: its provider's team, else the team + /// persisted with a restored pane or workspace. Nil when unknown. + func ownerTeamID(forMachineID machineID: String) -> String? { + providers[machineID]?.ownerTeamID ?? adoptedOwnerTeams[machineID] + } + + /// Records the owning team persisted with a restored pane or workspace, so + /// the machine reconnects with that team even when another is selected. + /// + /// - Parameters: + /// - teamID: The persisted owning team; nil or blank is ignored. + /// - machineID: The Cloud machine id. + func adoptOwnerTeam(_ teamID: String?, forMachineID machineID: String) { + guard let team = teamID?.trimmingCharacters(in: .whitespacesAndNewlines), !team.isEmpty, + WorkspaceCloudVMBinding.normalizedVMID(machineID) != nil, !machineID.hasPrefix("ssh:"), + providers[machineID] == nil, adoptedOwnerTeams[machineID] != team else { return } + adoptedOwnerTeams[machineID] = team + guard !isRetired, team != activeTeamID() else { return } + Task { [weak self] in _ = await self?.refresh(force: false) } + } + + /// Registers and updates machines of teams other than the selected one + /// that back an open or restoring pane, reading each with its own team. + /// A permanent access loss ends the machine's panes in a visible card. + private func refreshForeignOwnedMachines() async { + guard !isRetired, let catalog else { return } + let active = activeTeamID() + let needed = Set(catalog.projectedMachines.compactMap(\.cloudMachineID)).union(catalog.pendingRestoredMachineIDs) + let targets: [(id: String, team: String)] = needed.sorted().compactMap { id in + if let provider = providers[id] { + guard let owner = provider.ownerTeamID, owner != active, !provider.hasLostAccess else { return nil } + return (id, owner) + } + guard let team = adoptedOwnerTeams[id], team != active else { return nil } + return (id, team) + } + guard !targets.isEmpty else { return } + let epoch = accessEpoch + let generation = refreshGeneration + for target in targets { + let summary: VMSummary + do { + summary = try await loadMachineStatus(target.id, target.team) + } catch { + guard !isRetired, epoch == accessEpoch, generation == refreshGeneration else { return } + guard CloudMachineAccessLoss(error: error) != nil else { continue } + if let provider = providers[target.id] { + provider.noteAccessLost() + } else { + // No provider ever reached this machine; its restored + // panes leave like any machine outside the user's reach. + adoptedOwnerTeams[target.id] = nil + unregisterMachine(target.id) + } + continue + } + guard !isRetired, epoch == accessEpoch, generation == refreshGeneration else { return } + if let provider = providers[target.id] { + provider.update(summary: summary) + continue + } + guard machineTeardowns[registeredMachineID(matching: target.id)] == nil else { continue } + await links.setPrivateAddresses([summary.addressIPv4, summary.addressIPv6].compactMap { $0 }, for: summary.id) + await links.setOwnerTeam(target.team, for: summary.id) + guard !isRetired, epoch == accessEpoch, generation == refreshGeneration, + providers[summary.id] == nil else { return } + // No selected-team file-access scope: the file explorer stays + // limited to the selected team's machines. + let provider = CmuxTuiSurfaceProvider( + summary: summary, ownerTeamID: target.team, links: links, catalog: catalog, + portForwards: portForwards, portAccessStore: portAccess + ) + providers[summary.id] = provider + catalog.register(provider) + } + } + + /// The selected team changed for the same account. + /// + /// Cloud surfaces are owned by the team that created them, and every + /// control-plane call names that team, so open terminals and browsers of + /// every team keep running. Only discovery moves: in-flight reads and create + /// receipts of the previous selection are dropped, providers of another + /// team that no surface uses are retired, and the new team's fleet is read. + /// The WireGuard hub and its user-scoped enrollment are untouched. + func teamScopeDidChange() async { + guard !isRetired else { return } + creationEpoch = UUID() + pendingMachineCreationIDs.removeAll(); hasCompletedInitialRefresh = false; refreshedMachineIDs.removeAll() + createdTrustedCarrierIDs.removeAll() + refreshGeneration &+= 1 + discoveryInFlight?.cancel() + discoveryInFlight = nil + refreshInFlight?.cancel() + refreshInFlight = nil + let active = activeTeamID() + let retained = retainedForeignTeamMachineIDs(activeTeamID: active) + for (id, provider) in providers where provider.ownerTeamID != active && !retained.contains(id) { + unregisterMachine(id) + } + _ = await refresh(force: true) + } + /// The provider for a machine that may have been created a moment ago (`cmux vm new` /// opens its terminal right after `POST /api/vm` returns): when the registry has not /// listed it yet, re-read the fleet once instead of failing with "no provider". @@ -408,6 +566,10 @@ final class CmuxTuiSurfaceProviderRegistry { let epoch = accessEpoch _ = await discoverMachines(force: true, updateExisting: false) guard !isRetired, epoch == accessEpoch, isCloudEnabled(), !Task.isCancelled else { return nil } + if providers[machineID] == nil, adoptedOwnerTeams[machineID] != nil { + await refreshForeignOwnedMachines() + guard !isRetired, epoch == accessEpoch, isCloudEnabled(), !Task.isCancelled else { return nil } + } return providers[machineID] } @@ -422,13 +584,15 @@ final class CmuxTuiSurfaceProviderRegistry { } /// Deletion and discovery share ordered teardown without waiting for unrelated machines. - private func unregisterMachine(_ rawID: String) { + private func unregisterMachine(_ rawID: String, stopReason: CloudTuiManualMirrorStopReason = .accessLost) { // Match the registered casing so every ownership table is removed. let id = registeredMachineID(matching: rawID) pendingMachineCreationIDs.remove(id); refreshedMachineIDs.remove(.cloud(id)) createdTrustedCarrierIDs.remove(id) let provider = providers.removeValue(forKey: id) - provider?.suspendForFeatureFlag() + // The machine left the owning team's list (deleted, or the user lost + // access). Open panes keep the access-lost card, never a frozen frame. + provider?.suspendForFeatureFlag(stopReason: stopReason) catalog?.removeCloudMachine(.cloud(id)) // Teardowns for one machine run in order: a repeated delete waits for // the earlier pass instead of racing it (cancellation would not stop @@ -438,7 +602,7 @@ final class CmuxTuiSurfaceProviderRegistry { machineTeardowns[id] = Task { [links, portForwards, portAccess] in await previousTeardown?.value if let provider { - await provider.stop() + await provider.stop(stopReason: stopReason) } else { await portAccess.remove(machineID: id) } @@ -473,8 +637,12 @@ final class CmuxTuiSurfaceProviderRegistry { // MARK: - internals private func performDiscovery(generation: UInt64, updateExisting: Bool) async -> [CmuxTuiSurfaceProvider]? { + // The page belongs to the team selected when the read started. The + // client cancels the read if the selection changes before it returns. + let pageTeamID = activeTeamID() guard !isRetired, let catalog, let page = await listPage() else { return nil } - guard !isRetired, generation == refreshGeneration, isCloudEnabled(), !Task.isCancelled else { return nil } + guard !isRetired, generation == refreshGeneration, isCloudEnabled(), !Task.isCancelled, + pageTeamID == activeTeamID() else { return nil } if allowsBackgroundWork() { await wireGuardHub?.prepareForCloudUse() } guard !isRetired, generation == refreshGeneration, isCloudEnabled(), !Task.isCancelled else { return nil } let seen = Set(page.vms.map(\.id)) @@ -484,15 +652,21 @@ final class CmuxTuiSurfaceProviderRegistry { // Reconcile both stores. A restored catalog can contain a machine for // which this process has not created a provider yet. let catalogMachineIDs = Set(catalog.machines.keys.compactMap(\.cloudMachineID)) + // Another team's machines are not on this page. They stay while an + // open surface uses them; that team's own page or an access-denied + // answer is what retires them, never this team's list. + let retainedForeignIDs = retainedForeignTeamMachineIDs(activeTeamID: pageTeamID) + .union(pendingForeignRestoredMachineIDs(activeTeamID: pageTeamID)) let staleIDs = Set(providers.keys) .union(catalogMachineIDs) .union(catalog.pendingRestoredMachineIDs) .subtracting(pendingMachineCreationIDs) .subtracting(seen) + .subtracting(retainedForeignIDs) for id in staleIDs { unregisterMachine(id) } - await links.retainAddresses(machineIDs: seen) + await links.retainAddresses(machineIDs: seen.union(retainedForeignIDs)) guard !isRetired, generation == refreshGeneration else { return nil } for summary in page.vms { guard !isRetired else { return nil } @@ -518,8 +692,11 @@ final class CmuxTuiSurfaceProviderRegistry { if let provider = providers[summary.id] { provider.update(summary: summary) } else { + await links.setOwnerTeam(pageTeamID, for: summary.id) + guard generation == refreshGeneration else { return nil } let provider = CmuxTuiSurfaceProvider( - summary: summary, fileAccessTeamScope: AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope, links: links, catalog: catalog, + summary: summary, fileAccessTeamScope: AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope, + ownerTeamID: pageTeamID, links: links, catalog: catalog, portForwards: portForwards, portAccessStore: portAccess ) providers[summary.id] = provider @@ -539,6 +716,7 @@ final class CmuxTuiSurfaceProviderRegistry { /// Synchronous publication fence shared by team switching and full teardown. private func invalidateAccess() { networkObserver = nil + adoptedOwnerTeams.removeAll() isRetired = true accessEpoch &+= 1 creationEpoch = UUID() @@ -555,7 +733,7 @@ final class CmuxTuiSurfaceProviderRegistry { featureResumeTask = nil // Suspend before unregistering so terminal callbacks cannot race a // catalog removal during account teardown. - for provider in providers.values { provider.suspendForFeatureFlag() } + for provider in providers.values { provider.suspendForFeatureFlag(stopReason: .signedOut) } let retiredIDs = Set(providers.keys).union(catalog?.machines.keys.compactMap(\.cloudMachineID) ?? []) for id in retiredIDs { catalog?.unregister(machine: .cloud(id)) } } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 3f9d13f10053..5963e1bdac62 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -12,6 +12,15 @@ import Foundation @MainActor final class CmuxTuiSurfaceProvider: SurfaceProvider { let fileAccessTeamScope: AuthenticatedTeamScope? + /// The team that owns this machine, captured when the provider was + /// registered. Every control-plane call this provider makes names it, so a + /// Cloud surface keeps working after the selected team changes. Nil for SSH + /// machines and legacy callers, which follow the selected team. + let ownerTeamID: String? + /// Set once the control plane answered that this user can no longer reach + /// the machine (404 `vm_not_found`, 403, `vm_owner_mismatch`). Refreshes + /// stop dialing and every attached pane shows the access-lost card. + var hasLostAccess = false let machineID: String var machine: SurfaceMachineID { summary.machine } private(set) var info: SurfaceMachineInfo @@ -152,6 +161,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { init( summary: RemoteTuiMachine, fileAccessTeamScope: AuthenticatedTeamScope? = nil, + ownerTeamID: String? = nil, links: any RemoteTuiLinkManaging, catalog: SurfaceCatalog, portForwards: CloudHubPortForwarder? = nil, @@ -159,12 +169,11 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { portAccessStore: CloudPortAccessStore? = nil, displayCoordinator: CloudDisplayCoordinator? = nil, browserPolicy: @escaping @MainActor () -> BrowserURLAllowlistPolicy = { BrowserURLAllowlistPolicy() }, - loadPortSummary: @escaping @MainActor (String) async throws -> VMSummary = { id in - guard let client = VMClient.shared else { throw ProviderError.notSignedIn } - return try await client.status(id: id) - } + loadPortSummary: (@MainActor (String) async throws -> VMSummary)? = nil ) { self.fileAccessTeamScope = fileAccessTeamScope + let ownerTeamID = ownerTeamID ?? fileAccessTeamScope?.teamID + self.ownerTeamID = ownerTeamID machineID = summary.id self.attachmentClock = attachmentClock self.summary = summary @@ -174,10 +183,13 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { self.portAccessStore = portAccessStore ?? CloudPortAccessStore() self.displayCoordinator = displayCoordinator ?? CloudDisplayCoordinator { command, timeout in guard summary.cloudSummary != nil, let client = VMClient.shared else { throw ProviderError.notSignedIn } - return try await client.exec(id: summary.id, command: command, timeoutMs: timeout) + return try await client.exec(id: summary.id, command: command, timeoutMs: timeout, teamID: ownerTeamID) } self.browserPolicy = browserPolicy - self.loadPortSummary = loadPortSummary + self.loadPortSummary = loadPortSummary ?? { id in + guard let client = VMClient.shared else { throw ProviderError.notSignedIn } + return try await client.status(id: id, teamID: ownerTeamID) + } portDiscovery.reconcile( supportsPreviews: summary.capabilities.ports || summary.preferredPrivateAddress != nil, isAwake: summary.status == "running", @@ -233,7 +245,11 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { refreshCloudBrowserRoutes() } } - func suspendForFeatureFlag() { + /// Retires every attachment and transport task this provider owns. + /// + /// - Parameter stopReason: What open panes present afterwards. Panes stay + /// open; each keeps a card for this reason instead of a frozen frame. + func suspendForFeatureFlag(stopReason: CloudTuiManualMirrorStopReason = .cloudUnavailable) { isFeatureSuspended = true // The first read after resuming must arm afresh, never adopt at once. equalCursorConflict = nil @@ -265,7 +281,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { stateRecoveryRefreshQueued = false stateRecoveryCount = 0 eventsFeedWarning = nil - for session in manualMirrorSessions.values { session.stop() } + for session in manualMirrorSessions.values { session.stop(reason: stopReason) } manualMirrorSessions.removeAll() manualMirrorSurfaceIDsSocketPath = nil attachmentRetry.cancel() @@ -275,8 +291,35 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { pendingRemoteRenames.removeAll() acceptedCloudGenerations.removeAll(); catalog.notifyChange(for: machine) } + /// The control plane answered that this user can no longer reach the + /// machine. Automatic reconnects stop, and every attached pane shows the + /// access-lost card; the pane itself stays open so the user decides. + func noteAccessLost() { + guard !hasLostAccess else { return } + hasLostAccess = true + for task in restoredAttachTasks.values { task.cancel() } + restoredAttachTasks.removeAll() + attachmentRetry.cancel() + for session in manualMirrorSessions.values { session.stop(reason: .accessLost) } + manualMirrorSessions.removeAll() + manualMirrorSurfaceIDsSocketPath = nil + let detail = CloudTuiManualMirrorStopReason.accessLost.endedPresentation?.detail ?? "" + // Cloud browser and display panes of this machine keep the same state, + // in the browser's own unavailable card. + for task in browserPaneTasks.values { task.cancel() } + browserPaneTasks.removeAll() + for projection in catalog.projections where projection.resource.machine == machine { + AppDelegate.shared?.browserPanel(for: projection.panelID)?.cloudAccess.showUnavailable(detail) + } + guard isRegisteredInCatalog() else { return } + info.linkState = .error + info.linkError = detail + catalog.updateMachine(info, from: self) + } + /// One refresh pass. Sleeping machines retain their graph without being woken. func performRefresh(force: Bool) async -> Bool { + guard !hasLostAccess else { return false } let lifecycle = lifecycleGeneration guard isCurrentLifecycleGeneration(lifecycle), isRegisteredInCatalog() else { return false } refreshGeneration &+= 1 @@ -351,7 +394,10 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { if hasDesktop, catalog.authoritativeSnapshot.resources(on: machine).isEmpty { catalog.replaceResources(displayResources, on: machine, info: info, from: self) } - let statsRead = Task { try? await vmClient?.stats(id: machineID) } + let statsRead = Task { [ownerTeamID] () -> Result? in + guard let vmClient else { return nil } + do { return .success(try await vmClient.stats(id: machineID, teamID: ownerTeamID)) } catch { return .failure(error) } + } var linkState: SurfaceLinkState = .connected var linkError: String? // A decoded snapshot is not automatically an authorization boundary. It @@ -434,6 +480,11 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { ) else { return false } } catch { guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } + if CloudMachineAccessLoss(error: error) != nil { + // Retrying cannot succeed; a retry loop would only keep a frozen pane. + noteAccessLost() + return false + } portDiscovery.linkFailed() let status = await links.status(machineID: machineID) linkState = eventsFeedWarning == nil ? (status?.state ?? .error) : .error @@ -520,10 +571,18 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } } Task { [weak self] in - if let stats = await statsRead.value, - let self, self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation) { + guard let result = await statsRead.value, let self else { return } + switch result { + case .success(let stats): + guard self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return } self.info = self.info.applyingGauges(stats) self.catalog.updateMachine(self.info, from: self) + case .failure(let error): + // A carrier link can outlive a revoked membership; the owning + // team's authorized read is the first to learn it. + guard self.isCurrentLifecycleGeneration(lifecycle), + CloudMachineAccessLoss(error: error) != nil else { return } + self.noteAccessLost() } } return snapshotEstablishedCurrentGraph diff --git a/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift b/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift index 228ad13d8108..22965841fd43 100644 --- a/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift +++ b/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift @@ -77,7 +77,8 @@ extension SurfaceCatalog { workspace.cloudVMBinding = WorkspaceCloudVMBinding( vmID: target.machine.rawValue, isBase: previous?.vmID == target.machine.rawValue ? (previous?.isBase ?? false) : false, - remoteWorkspaceID: target.remoteWorkspaceID + remoteWorkspaceID: target.remoteWorkspaceID, + teamID: WorkspaceCloudVMBinding.owningTeamID(forVMID: target.machine.rawValue, previous: previous) ) } let write = enqueueRemoteWorkspaceRename(on: target.machine, id: target.remoteWorkspaceID, name: name) diff --git a/Sources/Surfaces/Workspace+CloudMachineTeams.swift b/Sources/Surfaces/Workspace+CloudMachineTeams.swift new file mode 100644 index 000000000000..dd0a0073ce44 --- /dev/null +++ b/Sources/Surfaces/Workspace+CloudMachineTeams.swift @@ -0,0 +1,31 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension Workspace { + /// The owning team of every Cloud machine this workspace shows, persisted + /// so its panes reconnect with that team after a restart even when another + /// team is selected. Nil when the workspace shows no Cloud machine. + var cloudMachineTeamsForSession: [String: String]? { + var machineIDs = Set((surfaceProjectionRecordsForSession ?? []).compactMap(\.resource.machine.cloudMachineID)) + if let binding = cloudVMBinding, !binding.vmID.hasPrefix("ssh:") { machineIDs.insert(binding.vmID) } + var teams: [String: String] = [:] + for machineID in machineIDs { + let previous = cloudVMBinding?.vmID == machineID ? cloudVMBinding : nil + if let team = WorkspaceCloudVMBinding.owningTeamID(forVMID: machineID, previous: previous) { + teams[machineID] = team + } + } + return teams.isEmpty ? nil : teams + } + + /// Hands persisted owning teams to the Cloud registry before panes restore. + func adoptRestoredCloudMachineTeams(_ snapshot: SessionWorkspaceSnapshot) { + let registry = CmuxTuiSurfaceProviderRegistry.shared + for (machineID, teamID) in snapshot.cloudMachineTeams ?? [:] { + registry.adoptOwnerTeam(teamID, forMachineID: machineID) + } + if let binding = snapshot.cloudVM { + registry.adoptOwnerTeam(binding.teamID, forMachineID: binding.vmID) + } + } +} diff --git a/Sources/Surfaces/Workspace+CloudPaneRouting.swift b/Sources/Surfaces/Workspace+CloudPaneRouting.swift index a9d72b6eac4a..8086b1337827 100644 --- a/Sources/Surfaces/Workspace+CloudPaneRouting.swift +++ b/Sources/Surfaces/Workspace+CloudPaneRouting.swift @@ -322,7 +322,8 @@ extension CloudWorkspaceRenameService { workspace.cloudVMBinding = WorkspaceCloudVMBinding( vmID: vmID, isBase: isBase ?? (sameMachine ? (previousBinding?.isBase ?? false) : false), - remoteWorkspaceID: remoteWorkspaceID ?? (sameMachine ? previousBinding?.remoteWorkspaceID : nil) + remoteWorkspaceID: remoteWorkspaceID ?? (sameMachine ? previousBinding?.remoteWorkspaceID : nil), + teamID: WorkspaceCloudVMBinding.owningTeamID(forVMID: vmID, previous: previousBinding) ) // The placeholder is marked automatic at creation. An explicit user diff --git a/Sources/Workspace+SessionRestoreIdentity.swift b/Sources/Workspace+SessionRestoreIdentity.swift index 2baff112dcf9..913ae3ded92a 100644 --- a/Sources/Workspace+SessionRestoreIdentity.swift +++ b/Sources/Workspace+SessionRestoreIdentity.swift @@ -5,7 +5,11 @@ extension Workspace { nonisolated static func restoredCloudVMBinding(from snapshot: SessionCloudVMBindingSnapshot?) -> WorkspaceCloudVMBinding? { guard let snapshot, let vmID = WorkspaceCloudVMBinding.normalizedVMID(snapshot.vmID) else { return nil } let remote = snapshot.remoteWorkspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) - return WorkspaceCloudVMBinding(vmID: vmID, isBase: snapshot.isBase, remoteWorkspaceID: remote?.isEmpty == false ? remote : nil) + return WorkspaceCloudVMBinding( + vmID: vmID, isBase: snapshot.isBase, + remoteWorkspaceID: remote?.isEmpty == false ? remote : nil, + teamID: snapshot.teamID + ) } /// Re-adopts a persisted panel identity unless it is still live elsewhere. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c0600cf42b1f..8e2eac77cb19 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -197,8 +197,11 @@ extension Workspace { progress: progressSnapshot, gitBranch: gitBranchSnapshot, remote: remoteConfiguration?.sessionSnapshot(), - cloudVM: cloudVMBinding.map { SessionCloudVMBindingSnapshot(vmID: $0.vmID, isBase: $0.isBase, remoteWorkspaceID: $0.remoteWorkspaceID) }, + cloudVM: cloudVMBinding.map { + SessionCloudVMBindingSnapshot(vmID: $0.vmID, isBase: $0.isBase, remoteWorkspaceID: $0.remoteWorkspaceID, teamID: $0.teamID) + }, surfaceProjections: surfaceProjectionRecordsForSession, + cloudMachineTeams: cloudMachineTeamsForSession, environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment ) snapshot.captureTodoState(from: self) @@ -286,7 +289,12 @@ extension Workspace { } // The binding survives restore so the machine's workspace is found again; its pane's // link was a process and is not reconnected here (a fresh open re-attaches). - cloudVMBinding = Self.restoredCloudVMBinding(from: snapshot.cloudVM) + // Owning teams go to the registry before any pane restores, so a pane + // of a team other than the selected one reconnects with its own team. + adoptRestoredCloudMachineTeams(snapshot) + // A legacy binding without a team adopts the selected team and is + // persisted with it from then on. + cloudVMBinding = Self.restoredCloudVMBinding(from: snapshot.cloudVM)?.adoptingOwningTeam() let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { @@ -775,7 +783,8 @@ extension Workspace { forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings, transparentBackground: browserPanel.sessionSnapshotTransparentBackground, diffViewerToken: diffViewerComponents?.token, - diffViewerRequestPath: diffViewerComponents?.requestPath, cloudResource: browserPanel.cloudResourceForSession + diffViewerRequestPath: diffViewerComponents?.requestPath, cloudResource: browserPanel.cloudResourceForSession, + cloudTeamID: browserPanel.cloudTeamIDForSession ) } else if let deferredPanel = panel as? DeferredBrowserPanel { // A deferred panel already owns the exact persisted browser DTO; diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 972c09a1c57b..a63088dfb677 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -754,6 +754,7 @@ C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */; }; 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */; }; C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */; }; + 09EC3FA75748545DB6304161 /* CloudMultiTeamSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B302C8E94D0ADF37D11FC5 /* CloudMultiTeamSurfaceTests.swift */; }; AF4D952CE6254D3D8D886EBC /* CloudNameAuthorityFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9A7787BF0744F3981CFD375 /* CloudNameAuthorityFixture.swift */; }; EF3CE50369C1428F8F611B2F /* CloudNameAuthorityTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6CAE6E212495C917D9568 /* CloudNameAuthorityTestProvider.swift */; }; BC9AC2D1065E4E88A48074BC /* CloudNameAuthorityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D0D38FD741430E9EBBBA9E /* CloudNameAuthorityTests.swift */; }; @@ -944,6 +945,7 @@ 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */; }; F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; + 224DE6896429A7E4224762B5 /* CloudTuiManualMirrorStopReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5D1E2BA55BA3938CF26AB4 /* CloudTuiManualMirrorStopReason.swift */; }; 0F2A151D749994FC57D403CA /* CloudTunnelActivationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9D51274EC32D701633288E /* CloudTunnelActivationObserver.swift */; }; 7A0CE1000000000000000730 /* CloudTunnelBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE100000000000000072F /* CloudTunnelBannerTests.swift */; }; 7A0CE1000000000000000104 /* CloudTunnelCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000103 /* CloudTunnelCoordinatorTests.swift */; }; @@ -3887,6 +3889,7 @@ C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */; }; A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */; }; A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3340001A3340001A3340001 /* ViewerNavigationTests.swift */; }; + CC1B8A02F5C016F54077FA5A /* VMClientReadCoalescingTests+ExplicitTeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5071BE1E5928573201D563E9 /* VMClientReadCoalescingTests+ExplicitTeam.swift */; }; C13151C00000000000000033 /* VMClientReadCoalescingTests+Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000032 /* VMClientReadCoalescingTests+Identity.swift */; }; 9D44FE10A33B02E0D4C891B1 /* VMClientReadCoalescingTests+ListRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F21D96D760755C4BDC2B1B /* VMClientReadCoalescingTests+ListRecovery.swift */; }; C12625000000000000000002 /* VMClientReadCoalescingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000001 /* VMClientReadCoalescingTests.swift */; }; @@ -3949,6 +3952,7 @@ C13304000000000000000003 /* Workspace+CloudDragSplitRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13304000000000000000004 /* Workspace+CloudDragSplitRouting.swift */; }; 4F2E42F6D7164832B891F079 /* Workspace+CloudLayoutProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */; }; 6744319688BC4C31AA910FEE /* Workspace+CloudMachineLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FCC29018E3742939CBCAE90 /* Workspace+CloudMachineLoading.swift */; }; + 3D7A10E43743B783CA690E1F /* Workspace+CloudMachineTeams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */; }; C11323010000000000000001 /* Workspace+CloudManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323020000000000000001 /* Workspace+CloudManualMirror.swift */; }; 65221C81A129236D52702D99 /* Workspace+CloudPaneRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5797FE9CD70513E4FFAC6EB /* Workspace+CloudPaneRouting.swift */; }; 74ED9CE6230A97ED8BBCAB0C /* Workspace+CloudPlacementFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */; }; @@ -4036,6 +4040,7 @@ D7AB3605C10DEF0000000003 /* WorkspaceCloseTabsBatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB3605C10DEF0000000004 /* WorkspaceCloseTabsBatching.swift */; }; D7AB3605C10DEF0000000001 /* WorkspaceCloseTabsContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB3605C10DEF0000000002 /* WorkspaceCloseTabsContextMenuTests.swift */; }; ED4BE012ED4BE012ED4BE012 /* WorkspaceCloudBindingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4BE013ED4BE013ED4BE013 /* WorkspaceCloudBindingState.swift */; }; + D00A8E8C47DD8DC05D500C66 /* WorkspaceCloudVMBinding+OwningTeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA8781ABBE7A67C2E61F17D /* WorkspaceCloudVMBinding+OwningTeam.swift */; }; 266DFDCC29A6299F0CC1091D /* WorkspaceCloudVMBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B66C7DD8A6A89CAF4027DA /* WorkspaceCloudVMBinding.swift */; }; 970900030000000000000001 /* WorkspaceColorMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970900030000000000000002 /* WorkspaceColorMenuTests.swift */; }; A5FB1203 /* WorkspaceConfigActionCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1204 /* WorkspaceConfigActionCapture.swift */; }; @@ -5021,6 +5026,7 @@ C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorPresentationTests.swift; sourceTree = ""; }; EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorSocketFixture.swift; sourceTree = ""; }; C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorTransportTests.swift; sourceTree = ""; }; + 03B302C8E94D0ADF37D11FC5 /* CloudMultiTeamSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMultiTeamSurfaceTests.swift"; sourceTree = ""; }; F9A7787BF0744F3981CFD375 /* CloudNameAuthorityFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudNameAuthorityFixture.swift"; sourceTree = ""; }; 20C6CAE6E212495C917D9568 /* CloudNameAuthorityTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudNameAuthorityTestProvider.swift"; sourceTree = ""; }; 10D0D38FD741430E9EBBBA9E /* CloudNameAuthorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudNameAuthorityTests.swift"; sourceTree = ""; }; @@ -5211,6 +5217,7 @@ 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift"; sourceTree = SOURCE_ROOT; }; 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+ReplayFidelity.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; + ED5D1E2BA55BA3938CF26AB4 /* CloudTuiManualMirrorStopReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorStopReason.swift; sourceTree = ""; }; 8E9D51274EC32D701633288E /* CloudTunnelActivationObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTunnelActivationObserver.swift; sourceTree = ""; }; 7A0CE100000000000000072F /* CloudTunnelBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTunnelBannerTests.swift; sourceTree = ""; }; 7A0CE1000000000000000103 /* CloudTunnelCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTunnelCoordinatorTests.swift; sourceTree = ""; }; @@ -8005,6 +8012,7 @@ C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+WorkspaceGroups.swift"; sourceTree = ""; }; A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ViewerNavigationKeyRouter.swift; sourceTree = ""; }; A3340001A3340001A3340001 /* ViewerNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerNavigationTests.swift; sourceTree = ""; }; + 5071BE1E5928573201D563E9 /* VMClientReadCoalescingTests+ExplicitTeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMClientReadCoalescingTests+ExplicitTeam.swift"; sourceTree = ""; }; C13151C00000000000000032 /* VMClientReadCoalescingTests+Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMClientReadCoalescingTests+Identity.swift"; sourceTree = ""; }; 06F21D96D760755C4BDC2B1B /* VMClientReadCoalescingTests+ListRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMClientReadCoalescingTests+ListRecovery.swift"; sourceTree = ""; }; C12625000000000000000001 /* VMClientReadCoalescingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMClientReadCoalescingTests.swift; sourceTree = ""; }; @@ -8064,6 +8072,7 @@ C13304000000000000000004 /* Workspace+CloudDragSplitRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudDragSplitRouting.swift"; sourceTree = ""; }; 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudLayoutProjection.swift"; sourceTree = ""; }; 7FCC29018E3742939CBCAE90 /* Workspace+CloudMachineLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudMachineLoading.swift"; sourceTree = ""; }; + 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudMachineTeams.swift"; sourceTree = ""; }; C11323020000000000000001 /* Workspace+CloudManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudManualMirror.swift"; sourceTree = ""; }; C5797FE9CD70513E4FFAC6EB /* Workspace+CloudPaneRouting.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudPaneRouting.swift"; sourceTree = ""; }; 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudPlacementFailure.swift"; sourceTree = ""; }; @@ -8151,6 +8160,7 @@ D7AB3605C10DEF0000000004 /* WorkspaceCloseTabsBatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCloseTabsBatching.swift; sourceTree = ""; }; D7AB3605C10DEF0000000002 /* WorkspaceCloseTabsContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCloseTabsContextMenuTests.swift; sourceTree = ""; }; ED4BE013ED4BE013ED4BE013 /* WorkspaceCloudBindingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCloudBindingState.swift; sourceTree = ""; }; + DFA8781ABBE7A67C2E61F17D /* WorkspaceCloudVMBinding+OwningTeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTui/WorkspaceCloudVMBinding+OwningTeam.swift"; sourceTree = ""; }; A5B66C7DD8A6A89CAF4027DA /* WorkspaceCloudVMBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTui/WorkspaceCloudVMBinding.swift"; sourceTree = ""; }; 970900030000000000000002 /* WorkspaceColorMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceColorMenuTests.swift; sourceTree = ""; }; A5FB1204 /* WorkspaceConfigActionCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceConfigActionCapture.swift; sourceTree = ""; }; @@ -8903,6 +8913,7 @@ F1BE71D511FF27AB7D0279A8 /* TerminalController+CloudMachinePayload.swift */, E52470C2BDCDB44AA3AF4B48 /* CloudWorkspaceCreationReveals.swift */, 16F81541F06BB9C88654913B /* SurfaceCatalog+MachineDeletion.swift */, + 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */, ); name = Surfaces; path = Surfaces; @@ -9066,6 +9077,7 @@ E3AF26EAE918E646929A4B58 /* CloudTreeOutlineView+RestoreState.swift */, 276B8742C711D7E718A5DC5A /* CloudTreePortPresentation.swift */, DBF12EF9962B6C396CFCFC9D /* CloudTreeDevicesSection+Controls.swift */, + ED5D1E2BA55BA3938CF26AB4 /* CloudTuiManualMirrorStopReason.swift */, ); name = Cloud; path = Cloud; @@ -11180,6 +11192,7 @@ 4845BF2DCEA4B76EC0EBC410 /* BrowserWindowSlotView+PaneDropOverlay.swift */, E7E4D51BF3365A4CB3A658E4 /* GhosttySurfaceScrollView+PaneDropOverlay.swift */, 2A6AE29EA7AF1FB12876663D /* UpdateRelaunchAgentClassification.swift */, + DFA8781ABBE7A67C2E61F17D /* WorkspaceCloudVMBinding+OwningTeam.swift */, ); path = Sources; sourceTree = ""; @@ -12605,6 +12618,8 @@ 09DC7DE176A93C580A263ADD /* PaneDropTargetIdentityTests.swift */, 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, 4AD84F07ADB08D5E8EBAA10D /* DeviceDiscoverabilityGatingTests.swift */, + 03B302C8E94D0ADF37D11FC5 /* CloudMultiTeamSurfaceTests.swift */, + 5071BE1E5928573201D563E9 /* VMClientReadCoalescingTests+ExplicitTeam.swift */, ); path = cmuxTests; sourceTree = ""; @@ -13883,6 +13898,7 @@ 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */, F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, + 224DE6896429A7E4224762B5 /* CloudTuiManualMirrorStopReason.swift in Sources */, 0F2A151D749994FC57D403CA /* CloudTunnelActivationObserver.swift in Sources */, C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */, CFSF00000000000000000003 /* CloudVMFileExplorerProvider.swift in Sources */, @@ -15683,6 +15699,7 @@ C13304000000000000000003 /* Workspace+CloudDragSplitRouting.swift in Sources */, 4F2E42F6D7164832B891F079 /* Workspace+CloudLayoutProjection.swift in Sources */, 6744319688BC4C31AA910FEE /* Workspace+CloudMachineLoading.swift in Sources */, + 3D7A10E43743B783CA690E1F /* Workspace+CloudMachineTeams.swift in Sources */, C11323010000000000000001 /* Workspace+CloudManualMirror.swift in Sources */, 65221C81A129236D52702D99 /* Workspace+CloudPaneRouting.swift in Sources */, 74ED9CE6230A97ED8BBCAB0C /* Workspace+CloudPlacementFailure.swift in Sources */, @@ -15763,6 +15780,7 @@ B2929B53F4CC4C9D972771EC /* WorkspaceChecklistAttachmentQuickLookController.swift in Sources */, D7AB3605C10DEF0000000003 /* WorkspaceCloseTabsBatching.swift in Sources */, ED4BE012ED4BE012ED4BE012 /* WorkspaceCloudBindingState.swift in Sources */, + D00A8E8C47DD8DC05D500C66 /* WorkspaceCloudVMBinding+OwningTeam.swift in Sources */, 266DFDCC29A6299F0CC1091D /* WorkspaceCloudVMBinding.swift in Sources */, A5FB1203 /* WorkspaceConfigActionCapture.swift in Sources */, 5732A0045732A0045732A004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift in Sources */, @@ -16471,6 +16489,7 @@ C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */, 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */, C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */, + 09EC3FA75748545DB6304161 /* CloudMultiTeamSurfaceTests.swift in Sources */, AF4D952CE6254D3D8D886EBC /* CloudNameAuthorityFixture.swift in Sources */, EF3CE50369C1428F8F611B2F /* CloudNameAuthorityTestProvider.swift in Sources */, BC9AC2D1065E4E88A48074BC /* CloudNameAuthorityTests.swift in Sources */, @@ -17310,6 +17329,7 @@ 62F6C9E77A6A40B982D91040 /* VaultSessionSearchQueryTests.swift in Sources */, C5BEA2B0EA914759A8BFD577 /* VaultSocketPayloadTests.swift in Sources */, A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, + CC1B8A02F5C016F54077FA5A /* VMClientReadCoalescingTests+ExplicitTeam.swift in Sources */, C13151C00000000000000033 /* VMClientReadCoalescingTests+Identity.swift in Sources */, 9D44FE10A33B02E0D4C891B1 /* VMClientReadCoalescingTests+ListRecovery.swift in Sources */, C12625000000000000000002 /* VMClientReadCoalescingTests.swift in Sources */, diff --git a/cmuxTests/CloudMultiTeamSurfaceTests.swift b/cmuxTests/CloudMultiTeamSurfaceTests.swift new file mode 100644 index 000000000000..5d2beecc3810 --- /dev/null +++ b/cmuxTests/CloudMultiTeamSurfaceTests.swift @@ -0,0 +1,258 @@ +import CmuxCloud +import CmuxCloudTui +import CmuxCore +import CmuxSurfaceCatalogModel +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A user can keep Cloud terminals and browsers of several teams open at once. +/// Switching the selected team moves only discovery; a real loss of access +/// ends in a visible card, never a frozen frame. +@MainActor +@Suite("Cloud surfaces across teams") +struct CloudMultiTeamSurfaceTests { + private let live = LiveWorkspaceFixture() + + @Test("A team switch keeps another team's open Cloud pane and its attachment alive") + func teamSwitchKeepsOtherTeamSession() async throws { + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + var activeTeam = "team-a" + let registry = CmuxTuiSurfaceProviderRegistry( + links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), + allowsBackgroundWork: { false }, + listPage: { + VMListPage(vms: activeTeam == "team-a" ? [Self.machine("vm-a"), Self.machine("vm-a-idle")] : [Self.machine("vm-b")], limits: nil) + }, + activeTeamID: { activeTeam }, + refreshProvider: { _, _ in true } + ) + registry.start(catalog: catalog) + #expect(await registry.refresh(force: true)) + let providerA = try #require(registry.provider(machineID: "vm-a")) + #expect(providerA.ownerTeamID == "team-a") + #expect(await registry.links.ownerTeam(for: "vm-a") == "team-a") + + // A team A terminal is open in a local workspace. + let panelID = UUID() + catalog.record(SurfaceProjection( + resource: SurfaceResourceID(machine: .cloud("vm-a"), kind: .terminal, key: "term_a"), + workspaceID: live.id(), panelID: panelID + )) + let session = CloudTuiManualMirrorSession( + machineID: "vm-a", terminalID: "term_a", remoteSurfaceID: 1, onNeedsReconnect: {} + ) + providerA.manualMirrorSessions[panelID] = session + let generation = providerA.currentLifecycleGeneration + + activeTeam = "team-b" + await registry.teamScopeDidChange() + + #expect(registry.provider(machineID: "vm-a") === providerA) + #expect(providerA.isCurrentLifecycleGeneration(generation)) + #expect(providerA.isRegisteredInCatalog()) + #expect(providerA.manualMirrorSessions[panelID] === session) + #expect(session.phase != .stopped) + #expect(session.stopReason == nil) + #expect(await registry.links.ownerTeam(for: "vm-a") == "team-a") + // The selected team's fleet is discovered; the old team's unused + // machine leaves, and the kept one is hidden from the new sidebar. + #expect(registry.provider(machineID: "vm-b")?.ownerTeamID == "team-b") + #expect(registry.provider(machineID: "vm-a-idle") == nil) + #expect(registry.foreignTeamMachineIDs == ["vm-a"]) + + // A later poll of team B's page still does not prune team A's pane. + #expect(await registry.refresh(force: true)) + #expect(registry.provider(machineID: "vm-a") === providerA) + #expect(session.phase != .stopped) + + // Sign-out ends every team and leaves the signed-out card. + await registry.accessDidEnd() + #expect(session.stopReason == .signedOut) + } + + @Test("A restored pane of a team that is not selected reconnects with its own team") + func restoredForeignTeamPaneReconnectsWithOwnTeam() async throws { + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + var statusTeams: [String: String] = [:] + let registry = CmuxTuiSurfaceProviderRegistry( + links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), + allowsBackgroundWork: { false }, + listPage: { VMListPage(vms: [Self.machine("vm-b")], limits: nil) }, + activeTeamID: { "team-b" }, + loadMachineStatus: { machineID, teamID in + statusTeams[machineID] = teamID + return Self.machine(machineID) + }, + refreshProvider: { _, _ in true } + ) + // After a restart, team A's pane restores while team B is selected. + catalog.restore([SurfaceProjectionRecord( + panelID: UUID(), resource: SurfaceResourceID(machine: .cloud("vm-a"), kind: .terminal, key: "term_a") + )], workspaceID: live.id()) + #expect(catalog.pendingRestoredMachineIDs == ["vm-a"]) + registry.adoptOwnerTeam("team-a", forMachineID: "vm-a") + registry.start(catalog: catalog) + + #expect(await registry.refresh(force: true)) + + let provider = try #require(registry.provider(machineID: "vm-a")) + #expect(provider.ownerTeamID == "team-a") + #expect(statusTeams == ["vm-a": "team-a"]) + #expect(await registry.links.ownerTeam(for: "vm-a") == "team-a") + #expect(registry.provider(machineID: "vm-b")?.ownerTeamID == "team-b") + #expect(registry.foreignTeamMachineIDs == ["vm-a"]) + #expect(registry.ownerTeamID(forMachineID: "vm-a") == "team-a") + await registry.accessDidEnd() + } + + @Test("A restored pane whose team access is gone does not keep reconnecting") + func restoredForeignTeamPaneWithLostAccessLeaves() async throws { + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + var statusReads = 0 + let registry = CmuxTuiSurfaceProviderRegistry( + links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), + allowsBackgroundWork: { false }, + listPage: { VMListPage(vms: [], limits: nil) }, + activeTeamID: { "team-b" }, + loadMachineStatus: { _, _ in + statusReads += 1 + throw VMClientError.httpStatus(403, #"{"error":"forbidden"}"#) + }, + refreshProvider: { _, _ in true } + ) + catalog.restore([SurfaceProjectionRecord( + panelID: UUID(), resource: SurfaceResourceID(machine: .cloud("vm-a"), kind: .terminal, key: "term_a") + )], workspaceID: live.id()) + registry.adoptOwnerTeam("team-a", forMachineID: "vm-a") + registry.start(catalog: catalog) + + #expect(await registry.refresh(force: true)) + #expect(registry.provider(machineID: "vm-a") == nil) + #expect(registry.ownerTeamID(forMachineID: "vm-a") == nil) + #expect(await registry.refresh(force: true)) + #expect(statusReads == 1) + await registry.accessDidEnd() + } + + @Test("A permanent 404 shows the access-lost card and stops retrying") + func permanentAccessLossStopsRetrying() async throws { + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + let links = AccessDeniedLinks() + // The link path is shared by every remote machine; an SSH summary + // reaches it without a signed-in control-plane client in the test host. + let provider = CmuxTuiSurfaceProvider( + summary: .ssh(Self.sshConnection()), ownerTeamID: "team-a", links: links, catalog: catalog + ) + catalog.register(provider) + let session = CloudTuiManualMirrorSession( + machineID: provider.machineID, terminalID: "term_gone", remoteSurfaceID: 1, onNeedsReconnect: {} + ) + provider.manualMirrorSessions[UUID()] = session + + #expect(await provider.refreshCurrentGraph(force: true) == false) + + #expect(provider.hasLostAccess) + #expect(session.phase == .stopped) + #expect(session.stopReason == .accessLost) + #expect(provider.manualMirrorSessions.isEmpty) + #expect(catalog.machines[provider.machine]?.linkState == .error) + #expect(catalog.machines[provider.machine]?.linkError == "You no longer have access to this machine.") + + #expect(await provider.refreshCurrentGraph(force: true) == false) + #expect(await links.connectCount == 1) + } + + @Test("A stopped attachment leaves its card until a new attachment binds") + func endedPresentationOutlivesSession() { + let overlay = CloudTerminalOverlayCoordinator() + let session = CloudTuiManualMirrorSession( + machineID: "vm", terminalID: "term", remoteSurfaceID: 1, onNeedsReconnect: {} + ) + overlay.session = session + let card = CloudTuiManualMirrorStopReason.accessLost.endedPresentation + #expect(card?.detail == "You no longer have access to this machine.") + #expect(card?.showsReconnectButton == false) + overlay.endSession(session, presentation: card) + #expect(overlay.session == nil) + #expect(overlay.endedPresentation == card) + + let replacement = CloudTuiManualMirrorSession( + machineID: "vm", terminalID: "term", remoteSurfaceID: 2, onNeedsReconnect: {} + ) + overlay.session = replacement + #expect(overlay.endedPresentation == nil) + #expect(CloudTuiManualMirrorStopReason.paneClosed.endedPresentation == nil) + } + + @Test("Cloud workspace bindings persist their owning team and decode legacy snapshots") + func bindingSnapshotRoundTrip() throws { + let snapshot = SessionCloudVMBindingSnapshot(vmID: "vm-a", isBase: false, remoteWorkspaceID: "ws_1", teamID: "team-a") + let decoded = try JSONDecoder().decode(SessionCloudVMBindingSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(decoded == snapshot) + #expect(Workspace.restoredCloudVMBinding(from: decoded)?.teamID == "team-a") + + let legacy = Data(#"{"vmID":"vm-a","isBase":true,"remoteWorkspaceID":"ws_1"}"#.utf8) + let legacyDecoded = try JSONDecoder().decode(SessionCloudVMBindingSnapshot.self, from: legacy) + #expect(legacyDecoded.teamID == nil) + let restored = try #require(Workspace.restoredCloudVMBinding(from: legacyDecoded)) + #expect(restored.teamID == nil) + #expect(restored.vmID == "vm-a") + #expect(restored.isBase) + } + + @Test("Cloud browser panes persist their owning team and decode legacy snapshots") + func browserSnapshotRoundTrip() throws { + let resource = SurfaceResourceID(machine: .cloud("vm-b"), kind: .display, key: "display") + let snapshot = SessionBrowserPanelSnapshot( + urlString: nil, profileID: nil, shouldRenderWebView: true, pageZoom: 1, + developerToolsVisible: false, backHistoryURLStrings: nil, forwardHistoryURLStrings: nil, + cloudResource: resource, cloudTeamID: "team-b" + ) + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(decoded.cloudResource == resource) + #expect(decoded.cloudTeamID == "team-b") + + let legacy = Data(#"{"shouldRenderWebView":true,"pageZoom":1,"developerToolsVisible":false}"#.utf8) + let legacyDecoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: legacy) + #expect(legacyDecoded.cloudTeamID == nil) + #expect(legacyDecoded.cloudResource == nil) + } + + private static func sshConnection() -> SSHTuiConnection { + SSHTuiConnection(configuration: WorkspaceRemoteConfiguration( + terminalProfile: .shell, destination: "alice@example.invalid", port: 2222, identityFile: nil, + sshOptions: [], localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, + localSocketPath: nil, terminalStartupCommand: nil, configuredRemoteCommand: nil, + preserveAfterTerminalExit: true + )) + } + + private static func machine(_ id: String) -> VMSummary { + VMSummary(id: id, provider: "freestyle", status: "running", image: "cmux-devbox", createdAt: 0, base: nil) + } +} + +/// A control plane that answers the first link attempt with `404 vm_not_found`. +private actor AccessDeniedLinks: RemoteTuiLinkManaging { + nonisolated let operations: CloudOperationRecorder? = nil + private(set) var connectCount = 0 + func connected(machineID: String) async throws -> CloudMachineLink.Connected { + connectCount += 1 + throw VMClientError.httpStatus(404, #"{"error":"vm_not_found"}"#) + } + func link(machineID: String) async -> CloudMachineLink? { nil } + func status(machineID: String) async -> CloudMachineLinkManager.LinkStatus? { nil } + func privateAddresses(for machineID: String) async -> [String] { [] } + func setPrivateAddresses(_ addresses: [String], for machineID: String) async {} + func browserProxy(machineID: String) async throws -> CloudBrowserProxyEndpoint { throw URLError(.cannotConnectToHost) } +} diff --git a/cmuxTests/CloudRefreshFixture.swift b/cmuxTests/CloudRefreshFixture.swift index caea6b93fca3..9101a2b1cd1c 100644 --- a/cmuxTests/CloudRefreshFixture.swift +++ b/cmuxTests/CloudRefreshFixture.swift @@ -22,7 +22,8 @@ struct CloudRefreshFixture { readRequests: CloudReadRequestCoordinator = CloudReadRequestCoordinator(), authClient: (any AuthClient)? = nil, isDisabledByManagedPolicy: (@Sendable () -> Bool)? = nil, - isCloudEnabled: @escaping @Sendable () -> Bool = { true } + isCloudEnabled: @escaping @Sendable () -> Bool = { true }, + fixtureTeams: Bool = false ) async throws -> Self { let defaults = try #require(UserDefaults(suiteName: "CloudRefreshFixture.\(UUID())")) let auth = AuthCoordinator( @@ -37,7 +38,9 @@ struct CloudRefreshFixture { ), launch: AuthLaunchOptions( clearAuthRequested: false, mockDataEnabled: false, - environment: ["CMUX_UITEST_AUTH_FIXTURE": "1", "CMUX_UITEST_AUTH_USER_ID": "fixture"], + environment: [ + "CMUX_UITEST_AUTH_FIXTURE": "1", "CMUX_UITEST_AUTH_USER_ID": "fixture", + ].merging(fixtureTeams ? ["CMUX_UITEST_AUTH_FIXTURE_TEAMS": "1"] : [:]) { current, _ in current }, includesDevAuth: true ) ) diff --git a/cmuxTests/CloudRefreshURLProtocol.swift b/cmuxTests/CloudRefreshURLProtocol.swift index 54187fcab635..75847bde1fe7 100644 --- a/cmuxTests/CloudRefreshURLProtocol.swift +++ b/cmuxTests/CloudRefreshURLProtocol.swift @@ -19,6 +19,8 @@ final class CloudRefreshURLProtocol: URLProtocol, @unchecked Sendable { static func waitUntilStopped(after baseline: Int) async { await responses.waitUntilStopped(after: baseline) } static func reset() async { await responses.reset() } static func requestCounts() async -> [String: Int] { await responses.counts } + /// The `X-Cmux-Team-Id` header of every request, in arrival order. + static func teamHeaders() async -> [String?] { await responses.teamHeaders } override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { Task { await Self.responses.start(self) } } @@ -26,6 +28,7 @@ final class CloudRefreshURLProtocol: URLProtocol, @unchecked Sendable { private actor Responses { private(set) var counts: [String: Int] = [:] + private(set) var teamHeaders: [String?] = [] private var tasks: [UUID: Task] = [:] private var behavior = Behavior.normal private var held = false @@ -58,6 +61,7 @@ final class CloudRefreshURLProtocol: URLProtocol, @unchecked Sendable { for task in tasks.values { task.cancel() } tasks.removeAll() counts.removeAll() + teamHeaders.removeAll() behavior = .normal stoppedRequests.removeAll() stopCount = 0 @@ -67,6 +71,7 @@ final class CloudRefreshURLProtocol: URLProtocol, @unchecked Sendable { guard !stoppedRequests.contains(key) else { return } let path = source.request.url!.path counts[path, default: 0] += 1 + teamHeaders.append(source.request.value(forHTTPHeaderField: "X-Cmux-Team-Id")) let count = counts.values.reduce(0, +) let ready = startWaiters.filter { $0.0 <= count } startWaiters.removeAll { $0.0 <= count } diff --git a/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift b/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift index 993d7fc735e8..b34d10750b56 100644 --- a/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift +++ b/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift @@ -164,12 +164,12 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { @Test("Pending machine receipts retire on deletion and team changes without affecting another create") func pendingMachineReceiptsRespectScopeAndDeletion() async { let catalog = SurfaceCatalog() - let notifications = NotificationCenter() + var activeTeam = "team-a" let registry = CmuxTuiSurfaceProviderRegistry( links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), allowsBackgroundWork: { false }, listPage: { VMListPage(vms: [], limits: nil) }, - notificationCenter: notifications + activeTeamID: { activeTeam } ) registry.start(catalog: catalog) let oldScope = registry.creationScope @@ -180,14 +180,14 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { #expect(await registry.refresh(force: true)) #expect(catalog.machines[.cloud("VM-Second")] != nil) - notifications.post(name: .cmuxCloudVMAccessDidEnd, object: nil, userInfo: ["cmux.teamSwitch": true]) - #expect(catalog.machines.isEmpty) + activeTeam = "team-b" + await registry.teamScopeDidChange() + // The previous team's receipt is no longer admitted, and an unlisted + // receipt no surface uses leaves with the previous selection. + #expect(registry.creationScope != oldScope) await registry.recordCreatedMachine(machine("late-old-team"), scope: oldScope) - #expect(catalog.machines.isEmpty) - #expect(registry.creationScope == nil) - #expect(await registry.refresh(force: true) == false) - await registry.accessDidEnd() - registry.start(catalog: catalog) + #expect(catalog.machines[.cloud("late-old-team")] == nil) + #expect(catalog.machines[.cloud("VM-Second")] == nil) await registry.recordCreatedMachine(machine("new-team"), scope: registry.creationScope) #expect(await registry.refresh(force: true)) #expect(Set(catalog.machines.keys) == [.cloud("new-team")]) @@ -199,7 +199,7 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { let catalog = SurfaceCatalog() let requested = CloudLinkFirstValue() let release = CloudLinkFirstValue() - let notifications = NotificationCenter() + var activeTeam = "team-a" var lists = 0 let registry = CmuxTuiSurfaceProviderRegistry( links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), @@ -207,23 +207,25 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { listPage: { lists += 1 if lists == 1 { return VMListPage(vms: [machine("known-old-team-vm")], limits: nil) } + if activeTeam == "team-b" { return VMListPage(vms: [], limits: nil) } requested.resolve(true) _ = await release.result return VMListPage(vms: [machine("old-team-vm")], limits: nil) }, - notificationCenter: notifications + activeTeamID: { activeTeam }, + refreshProvider: { _, _ in true } ) registry.start(catalog: catalog) - let provider = try #require(await registry.providerRefreshingIfMissing(machineID: "known-old-team-vm")) - let generation = provider.currentLifecycleGeneration + #expect(try #require(await registry.providerRefreshingIfMissing(machineID: "known-old-team-vm")).ownerTeamID == "team-a") let discovery = Task { await registry.providerRefreshingIfMissing(machineID: "old-team-vm") } #expect(await boundedResult(requested)) - notifications.post(name: .cmuxCloudVMAccessDidEnd, object: nil, userInfo: ["cmux.teamSwitch": true]) - #expect(!provider.isCurrentLifecycleGeneration(generation)) - #expect(!provider.isRegisteredInCatalog()) + activeTeam = "team-b" + let rescope = Task { await registry.teamScopeDidChange() } release.resolve(true) + await rescope.value #expect(await discovery.value == nil) - #expect(catalog.machines.isEmpty) + #expect(registry.provider(machineID: "old-team-vm") == nil) + #expect(catalog.machines[.cloud("old-team-vm")] == nil) await registry.accessDidEnd() } diff --git a/cmuxTests/VMClientReadCoalescingTests+ExplicitTeam.swift b/cmuxTests/VMClientReadCoalescingTests+ExplicitTeam.swift new file mode 100644 index 000000000000..f56872837d79 --- /dev/null +++ b/cmuxTests/VMClientReadCoalescingTests+ExplicitTeam.swift @@ -0,0 +1,74 @@ +import CmuxCloud +import CmuxAuthRuntime +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A Cloud surface names the team that owns its machine on every request, so +/// surfaces of several teams can stay open while the selected team changes. +@MainActor +extension VMClientReadCoalescingTests { + @Test("An explicit owning team is the request's team header") + func explicitTeamHeader() async throws { + let fixture = try await CloudRefreshFixture.make(authClient: TeamChangeAuthClient(), fixtureTeams: true) + defer { fixture.session.invalidateAndCancel() } + await CloudRefreshURLProtocol.reset() + try await fixture.auth.selectTeam(id: "team-a") + + _ = try await fixture.client.stats(id: "fixture-0", teamID: "team-b") + _ = try await fixture.client.stats(id: "fixture-1") + + #expect(await CloudRefreshURLProtocol.teamHeaders() == ["team-b", "team-a"]) + } + + @Test("Shared reads coalesce per owning team, never across teams") + func explicitTeamCoalescingKey() async throws { + let fixture = try await CloudRefreshFixture.make(authClient: TeamChangeAuthClient(), fixtureTeams: true) + defer { fixture.session.invalidateAndCancel() } + await CloudRefreshURLProtocol.reset() + try await fixture.auth.selectTeam(id: "team-a") + await CloudRefreshURLProtocol.holdResponses() + + let teamA = Task { try await fixture.client.stats(id: "fixture-0", teamID: "team-a") } + let teamB = Task { try await fixture.client.stats(id: "fixture-0", teamID: "team-b") } + await CloudRefreshURLProtocol.waitUntilStarted(2) + let joinedTeamB = Task { try await fixture.client.stats(id: "fixture-0", teamID: "team-b") } + await CloudRefreshURLProtocol.releaseResponses() + _ = try await teamA.value + _ = try await teamB.value + _ = try await joinedTeamB.value + + #expect(await CloudRefreshURLProtocol.requestCounts()["/api/vm/fixture-0/stats"] == 2) + #expect(Set(await CloudRefreshURLProtocol.teamHeaders().compactMap { $0 }) == ["team-a", "team-b"]) + } + + @Test("A selection change does not cancel an owning-team request, only a selected-team one") + func explicitTeamSurvivesSelectionChange() async throws { + let fixture = try await CloudRefreshFixture.make(authClient: TeamChangeAuthClient(), fixtureTeams: true) + defer { fixture.session.invalidateAndCancel() } + await CloudRefreshURLProtocol.reset() + try await fixture.auth.selectTeam(id: "team-a") + await CloudRefreshURLProtocol.holdResponses() + + let owned = Task { try await fixture.client.stats(id: "fixture-0", teamID: "team-a") } + let selected = Task { try await fixture.client.stats(id: "fixture-1") } + await CloudRefreshURLProtocol.waitUntilStarted(2) + try await fixture.auth.selectTeam(id: "team-b") + #expect(fixture.auth.resolvedTeamID == "team-b") + await CloudRefreshURLProtocol.releaseResponses() + + let ownedResult = await owned.result + #expect(throws: Never.self) { try ownedResult.get() } + do { + _ = try await selected.value + Issue.record("A read bound to the previous selection was published into the new team") + } catch is CancellationError { + } catch VMClientError.notSignedIn { + } catch { Issue.record("Unexpected selection-change error: \(error)") } + } +} From a0873f0253a5a4bb7142ad261daaeef8d8d5353a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:35:51 -0700 Subject: [PATCH 3/3] test: temporary hang diagnostics for Cloud graph suites Co-Authored-By: Claude Opus 5.5 (1M context) --- cmuxTests/CloudDesktopGraphOpenTests.swift | 35 +++++++++++++++++++ .../CloudInitialWorkspaceNamingTests.swift | 6 ++++ 2 files changed, 41 insertions(+) diff --git a/cmuxTests/CloudDesktopGraphOpenTests.swift b/cmuxTests/CloudDesktopGraphOpenTests.swift index 6b0b1c32998e..76660c125be7 100644 --- a/cmuxTests/CloudDesktopGraphOpenTests.swift +++ b/cmuxTests/CloudDesktopGraphOpenTests.swift @@ -17,9 +17,14 @@ struct CloudDesktopGraphOpenTests { @Test("Click, menu and drop keep their Desktop split across refresh and reconnect", arguments: CloudDesktopNavigationFixture.EntryPoint.allCases) func survivesRefresh(entryPoint: CloudDesktopNavigationFixture.EntryPoint) async throws { + let sampler = TempHangSampler.arm("graph-refresh", after: 35) + defer { sampler.cancel() } try await withFixture { fixture in + NSLog("HANGPHASE graph-refresh installGraph1") try await installGraph(in: fixture, revision: 1) + NSLog("HANGPHASE graph-refresh open") let browser = try await fixture.open(entryPoint) + NSLog("HANGPHASE graph-refresh opened") let pane = try #require(fixture.app.owner.paneId(forPanelId: browser.id)) let layout = fixture.app.owner.bonsplitController.treeSnapshot() let binding = fixture.app.owner.cloudVMBinding @@ -57,7 +62,9 @@ struct CloudDesktopGraphOpenTests { } private func withFixture(_ body: @MainActor (CloudDesktopNavigationFixture) async throws -> Void) async throws { + NSLog("HANGPHASE withFixture gate") try await AppContextSerialGate.withExclusiveAppContext { + NSLog("HANGPHASE withFixture entered") let fixture = try CloudDesktopNavigationFixture() let manager = fixture.app.app.manager fixture.app.catalog.installCloudWorkspaceRenameService(CloudWorkspaceRenameService(environment: .init( @@ -72,8 +79,11 @@ struct CloudDesktopGraphOpenTests { workspaceID: fixture.app.owner.id, panelID: terminal, remoteWorkspaceID: fixture.app.remote.id, remoteTabID: "terminal-tab" )) + NSLog("HANGPHASE withFixture start") try await fixture.start() + NSLog("HANGPHASE withFixture body") try await body(fixture) + NSLog("HANGPHASE withFixture body done") } catch { await fixture.close() throw error @@ -117,8 +127,33 @@ struct CloudDesktopGraphOpenTests { var info = fixture.provider.info info.remoteWorkspaces = [fixture.app.remote] fixture.app.catalog.replaceCloudState(state, resources: resources, info: info) + NSLog("HANGPHASE installGraph replaced") fixture.app.catalog.reconcileCloudRemoteState(machine: machine, state: state) + NSLog("HANGPHASE installGraph reconciled") await fixture.app.catalog.cloudWorkspaceProjectionCoordinator.waitForIdle() + NSLog("HANGPHASE installGraph idle") #expect(fixture.app.catalog.cloudWorkspaceProjectionCoordinator.failures.isEmpty) } } + +/// TEMPORARY hang diagnostics; removed before the final commit. +enum TempHangSampler { + static func arm(_ label: String, after seconds: Double) -> Task { + Task.detached { + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + let pid = getpid() + let out = "/tmp/hang-\(label)-\(pid).txt" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sample") + process.arguments = ["\(pid)", "2", "-mayDie", "-file", out] + try? process.run() + process.waitUntilExit() + let text = (try? String(contentsOfFile: out, encoding: .utf8)) ?? "no sample" + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + if let start = lines.firstIndex(where: { $0.contains("Call graph:") }) { lines = Array(lines[start...]) } + for line in lines.prefix(900) { print("HANGSAMPLE \(label) \(line)") } + fflush(stdout) + } + } +} diff --git a/cmuxTests/CloudInitialWorkspaceNamingTests.swift b/cmuxTests/CloudInitialWorkspaceNamingTests.swift index 208ec6ca59cf..fce2c51e55ec 100644 --- a/cmuxTests/CloudInitialWorkspaceNamingTests.swift +++ b/cmuxTests/CloudInitialWorkspaceNamingTests.swift @@ -101,17 +101,23 @@ struct CloudInitialWorkspaceNamingTests { @Test("Projection discovery submits a creation-time user rename before reconciling the old graph") func projectionBindingPreservesCreationRename() async throws { + let sampler = TempHangSampler.arm("naming-projection", after: 30) + defer { sampler.cancel() } try await withUnboundFixture { fixture in + NSLog("HANGPHASE naming bind") fixture.catalog.bindCloudWorkspace(localWorkspaceID: fixture.workspace.id, machine: fixture.provider.machine, remoteWorkspaceID: nil, generatedTitle: "Cloud VM") #expect(fixture.workspace.setCustomTitle("Chosen during creation", source: .user)) + NSLog("HANGPHASE naming record") fixture.catalog.record(SurfaceProjection( resource: .init(machine: fixture.provider.machine, kind: .terminal, key: "term_a"), workspaceID: fixture.workspace.id, panelID: fixture.panelID, remoteWorkspaceID: "a", remoteTabID: "tab_a" )) #expect(fixture.workspace.title == "Chosen during creation") + NSLog("HANGPHASE naming settle") try await fixture.settle() + NSLog("HANGPHASE naming settled") try fixture.expectParity("terminal", workspaceName: "Chosen during creation") #expect(fixture.provider.writes.map { $0.0 } == ["a"]) #expect(fixture.provider.graph.lookupIndex.workspace(id: "b")?.name == "Same workspace")