mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user