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
This commit is contained in:
Neil
2026-11-09 19:59:10 -08:00
committed by GitHub
parent 5e9fdffc41
commit 6bc1fffc67
4 changed files with 143 additions and 8 deletions
+9
View File
@@ -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) })
@@ -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()
+21 -8
View File
@@ -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 }
}
+63
View File
@@ -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<string, () => 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'
})
})
})