From 6bc1fffc67acd1c849da4e4e8e16ee1dcd1ddc49 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:59:10 -0800 Subject: [PATCH] W8: teammates wake a team workspace from the phone (team-route wake; owner route not widened) (#155) * feat(w8): teammates wake a team workspace from the phone (team route long-poll wake, same access/billing/cap; owner route stays the fallback) * test(w8): team wake refuses an admin on a private row, never returns the endpoint; phone never falls back on 401/403 --- cloud/apps/sandbox-control/src/index.ts | 9 +++ .../sandbox-control/test/workspaces.test.ts | 50 +++++++++++++++ .../src/cloud/cloud-control-plane-client.ts | 29 ++++++--- mobile/src/cloud/cloud-wake.test.ts | 63 +++++++++++++++++++ 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 mobile/src/cloud/cloud-wake.test.ts diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 0e883152d35..9bd23b0f51a 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -24,6 +24,8 @@ // cannot enroll devices, manage secrets or connect GitHub. // /v1/team/workspaces[/:id[/presence|wake|lease|grants[/:grantId]]], /v1/team/links/resolve, /v1/team/prompts // team workspace registry, presence, per-device grants (src/workspaces.ts, src/grants.ts) +// POST /v1/team/workspaces/:id/wake?wait=30s: any member who can open it wakes it (single-flight, +// billed to the account, counted on the owner's seat like every wake) -> {state, endpoint: null, …} // /v1/sandboxes/* is the owner's lifecycle API: only the workspace's owner (and, for suspend and // destroy, an org admin) can reach a row there, because resume hands back an orcad pairing offer. // Browser (GitHub redirects): GET /v1/github/setup, GET /v1/github/callback @@ -1306,6 +1308,13 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps, if (!canOpen(caller, row)) { throw new WorkspaceError('not_found') } + if (url.searchParams.has('wait')) { + // The same single-flight long-poll wake as the owner's POST /v1/sandboxes/:id/wake (phones), + // billed and capped exactly like it. The endpoint stays out: a teammate gets in only through + // their own grant, never by learning where the VM listens. + const answer = await wakeSandbox(life, row, { waitMs: parseWakeWait(url, null), ...workspaceConfig(env, deps) }) + return json({ ...answer, endpoint: null }) + } // Never returns the owner's pairing offer; teammates get in through their own grant. const ready = await resumeSandbox(life, row) return json({ workspace: await view(ready.row) }) diff --git a/cloud/apps/sandbox-control/test/workspaces.test.ts b/cloud/apps/sandbox-control/test/workspaces.test.ts index 89347652ec1..17fe65774f2 100644 --- a/cloud/apps/sandbox-control/test/workspaces.test.ts +++ b/cloud/apps/sandbox-control/test/workspaces.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { generateGrantRecipientKey, nodeSealedGrantPrimitives } from '../../../../src/main/cloud-workspaces/sealed-grant-node' import { openSealedGrant } from '../../../../src/shared/cloud-workspaces/sealed-grant' +import { HostedWakeResponseSchema } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' import { REVOKE_COMMAND } from '../src/grant-revokes' import { GRANT_COMMAND } from '../src/grants' import { createHarness } from './harness' @@ -236,6 +237,55 @@ describe('workspace grants (E2EE pairing through the control plane)', () => { }) }) +describe('team wake (phones)', () => { + it('a teammate wakes a team workspace with the single-flight wake, never learns the endpoint, billed to the team', async () => { + const t = await team() + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' }) + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/suspend`) + const checksBefore = t.h.billing.checks.length + const woke = await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=30s`) + expect(woke.status).toBe(200) + expect(HostedWakeResponseSchema.parse(woke.body)).toMatchObject({ state: 'ready', endpoint: null, sandboxId: t.id }) + // The same start check as the owner's own wake: the team's account pays. + expect(t.h.billing.checks.slice(checksBefore)).toEqual([expect.objectContaining({ accountId: 'org:acme' })]) + // Racing wakes from teammate and owner start the VM once. + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/suspend`) + const answers = await Promise.all([ + t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=30s`), + t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/wake?wait=30s`) + ]) + expect(answers.map((a) => a.body.state)).toEqual(['ready', 'ready']) + }) + + it('is not found for a non-member, for a private workspace, and for an archived one', async () => { + const t = await team() + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/suspend`) + // Private (the default): teammates cannot wake it. + expect((await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=1s`)).status).toBe(404) + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' }) + const outsider = await t.as('mallory', 'member', 'globex') + expect((await t.req(outsider, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=1s`)).status).toBe(404) + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/archive`, {}) + expect((await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=1s`)).status).toBe(404) + }) + + it('an org admin cannot wake a private workspace, and a running one never hands a teammate its endpoint', async () => { + const t = await team() + expect((await t.req(t.admin, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=1s`)).status).toBe(404) + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' }) + const running = await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake?wait=1s`) + expect(running.body).toMatchObject({ state: 'ready', endpoint: null }) + }) + + it('keeps the desktop blocking wake (no wait) answering {workspace}', async () => { + const t = await team() + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' }) + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/suspend`) + const woke = await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/wake`) + expect(woke.body.workspace).toMatchObject({ id: t.id, status: 'running' }) + }) +}) + describe('grant races', () => { it('a removal that lands while the grant is being issued wins: the new device is killed, not activated', async () => { const t = await team() diff --git a/mobile/src/cloud/cloud-control-plane-client.ts b/mobile/src/cloud/cloud-control-plane-client.ts index 6eef33f6281..e91dcbf81ff 100644 --- a/mobile/src/cloud/cloud-control-plane-client.ts +++ b/mobile/src/cloud/cloud-control-plane-client.ts @@ -155,19 +155,32 @@ export async function listCloudWorkspaces( export type CloudWakeState = HostedWakeResponse['state'] -/** One long-poll wake (the server holds it up to 30 s); idempotent and single-flight server side. */ +/** + * One long-poll wake (the server holds it up to 30 s); idempotent and single-flight server side. + * The team route lets any member who can open the workspace wake it (a teammate's too); the + * owner-only route stays the fallback for a server without it, or a row the registry can't serve. + */ export async function wakeCloudWorkspace( plane: CloudControlPlane, sandboxId: string, signal?: AbortSignal ): Promise<{ state: CloudWakeState; endpoint: string | null }> { - const body = await request( - plane, - 'POST', - `/v1/sandboxes/${encodeURIComponent(sandboxId)}/wake?wait=30s`, - HostedWakeResponseSchema, - { body: {}, timeoutMs: 45_000, signal } - ) + const id = encodeURIComponent(sandboxId) + const wake = (path: string) => + request(plane, 'POST', `${path}/wake?wait=30s`, HostedWakeResponseSchema, { + body: {}, + timeoutMs: 45_000, + signal + }) + let body: HostedWakeResponse + try { + body = await wake(`/v1/team/workspaces/${id}`) + } catch (error) { + if (!(error instanceof CloudRequestError) || error.status !== 404) { + throw error + } + body = await wake(`/v1/sandboxes/${id}`) + } return { state: body.state, endpoint: body.endpoint } } diff --git a/mobile/src/cloud/cloud-wake.test.ts b/mobile/src/cloud/cloud-wake.test.ts new file mode 100644 index 00000000000..dea3b8bc9e1 --- /dev/null +++ b/mobile/src/cloud/cloud-wake.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { wakeCloudWorkspace } from './cloud-control-plane-client' +import { CloudRequestError } from './cloud-control-plane-request' + +const ready = { state: 'ready', endpoint: null, readyAt: 1, sandboxId: 'sbx_a' } + +function plane(answers: Record Response>) { + const calls: string[] = [] + return { + calls, + plane: { + baseUrl: 'https://cp.test', + token: () => 'tok', + fetch: async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname + calls.push(path) + const answer = answers[path] + return answer ? answer() : Response.json({ error: 'not_found' }, { status: 404 }) + } + } + } +} + +describe('wakeCloudWorkspace', () => { + it('wakes through the team route, so a teammate can wake a team workspace', async () => { + const { plane: p, calls } = plane({ + '/v1/team/workspaces/sbx_a/wake': () => Response.json(ready) + }) + expect(await wakeCloudWorkspace(p, 'sbx_a')).toEqual({ state: 'ready', endpoint: null }) + expect(calls).toEqual(['/v1/team/workspaces/sbx_a/wake']) + }) + + it('falls back to the owner route when the team route is not found (older server, archived owner row)', async () => { + const { plane: p, calls } = plane({ + '/v1/sandboxes/sbx_a/wake': () => Response.json({ ...ready, state: 'archived' }) + }) + expect(await wakeCloudWorkspace(p, 'sbx_a')).toEqual({ state: 'archived', endpoint: null }) + expect(calls).toEqual(['/v1/team/workspaces/sbx_a/wake', '/v1/sandboxes/sbx_a/wake']) + }) + + it('does not retry the owner route on a server error, and reports not_found when neither route knows it', async () => { + const failing = plane({ + '/v1/team/workspaces/sbx_a/wake': () => Response.json({ error: 'internal' }, { status: 500 }) + }) + await expect(wakeCloudWorkspace(failing.plane, 'sbx_a')).rejects.toBeInstanceOf( + CloudRequestError + ) + expect(failing.calls).toEqual(['/v1/team/workspaces/sbx_a/wake']) + // Refusals other than not_found never retry on the owner route. + for (const status of [401, 403]) { + const refused = plane({ + '/v1/team/workspaces/sbx_a/wake': () => Response.json({ error: 'forbidden' }, { status }) + }) + await expect(wakeCloudWorkspace(refused.plane, 'sbx_a')).rejects.toMatchObject({ status }) + expect(refused.calls).toEqual(['/v1/team/workspaces/sbx_a/wake']) + } + const stranger = plane({}) + await expect(wakeCloudWorkspace(stranger.plane, 'sbx_a')).rejects.toMatchObject({ + status: 404, + code: 'not_found' + }) + }) +})