mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(sandbox-control): per-workspace tool egress hosts for synced remote MCP servers (PUT/GET /v1/sandboxes/:id/egress/hosts, migration 0032)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- Hosts the workspace's own agent tools need (remote MCP servers synced from the desktop), added to
|
||||
-- the egress allowlist on every boot (comma list). Set by PUT /v1/sandboxes/:id/egress/hosts; unlike
|
||||
-- egress_allow_hosts it is not recomputed from secrets.
|
||||
ALTER TABLE sandboxes ADD COLUMN egress_user_hosts TEXT;
|
||||
@@ -0,0 +1,55 @@
|
||||
// Hosts a workspace's own agent tools need beyond the default allowlist — today the remote MCP
|
||||
// servers the desktop syncs into the sandbox (MCP config sync). The desktop declares them with
|
||||
// PUT /v1/sandboxes/:id/egress/hosts {hosts}; they persist on the row (every boot re-applies them)
|
||||
// and a running sandbox's policy is rewritten at once. Public hostnames only: no wildcards, IPs,
|
||||
// localhost or private suffixes, so the fail-closed allowlist can't be turned into "open".
|
||||
import { z } from 'zod'
|
||||
import { egressPolicy, refreshGitCredentials, type LifecycleDeps } from './lifecycle'
|
||||
import { transition, type SandboxRow } from './store'
|
||||
|
||||
export const MAX_USER_HOSTS = 32
|
||||
const HOST = /^([a-z0-9-]+\.)+[a-z]{2,63}$/
|
||||
const PRIVATE_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home', '.corp', '.intranet']
|
||||
|
||||
export const EgressHostsSchema = z
|
||||
.object({ hosts: z.array(z.string().min(1).max(253)).max(MAX_USER_HOSTS) })
|
||||
.strict()
|
||||
|
||||
/** Normalized, deduplicated, sorted; null when any entry is not an acceptable public hostname. */
|
||||
export function normalizeUserHosts(hosts: readonly string[]): string[] | null {
|
||||
const out = new Set<string>()
|
||||
for (const raw of hosts) {
|
||||
const host = raw.trim().toLowerCase().replace(/\.$/, '')
|
||||
if (!HOST.test(host) || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
||||
return null
|
||||
}
|
||||
out.add(host)
|
||||
}
|
||||
return [...out].sort()
|
||||
}
|
||||
|
||||
export function egressUserHosts(row: Pick<SandboxRow, 'egress_user_hosts'>): string[] {
|
||||
return (row.egress_user_hosts ?? '').split(',').filter(Boolean)
|
||||
}
|
||||
|
||||
/** Records the hosts and, for a running sandbox, rewrites its policy now. */
|
||||
export async function setEgressUserHosts(deps: LifecycleDeps, row: SandboxRow, hosts: string[]): Promise<SandboxRow | null> {
|
||||
const value = hosts.join(',') || null
|
||||
if (value === row.egress_user_hosts) {
|
||||
return row
|
||||
}
|
||||
const next = await transition(deps.db, row, { egress_user_hosts: value }, deps.now())
|
||||
if (!next) {
|
||||
return null
|
||||
}
|
||||
if (next.status === 'running' && next.session_id) {
|
||||
// A policy write replaces the whole policy, so the git route is re-minted alongside.
|
||||
const git = await refreshGitCredentials(deps, next, next.session_id)
|
||||
if (!git) {
|
||||
await deps.provider.updateNetworkPolicy(next.session_id, egressPolicy(deps, next))
|
||||
}
|
||||
}
|
||||
// Counts only; hostnames are not secrets but stay out of logs anyway (they can name private tools).
|
||||
console.log(JSON.stringify({ event: 'egress_user_hosts_set', sandboxId: row.id, count: hosts.length }))
|
||||
return next
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { reconcile } from './reconcile'
|
||||
import { egressSecretHosts, secretPlaceholder } from './secret-egress'
|
||||
import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets'
|
||||
import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh'
|
||||
import { EgressHostsSchema, egressUserHosts, normalizeUserHosts, setEgressUserHosts } from './egress-hosts'
|
||||
import { cachedProviderEnv } from './session-env'
|
||||
import { credentialTargets } from './provider-env'
|
||||
import type { Keyring } from './secrets-crypto'
|
||||
@@ -972,7 +973,7 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
|
||||
return await workspaceRoute(request, url, env, deps, caller)
|
||||
}
|
||||
const match =
|
||||
/^\/v1\/sandboxes(?:\/([A-Za-z0-9_]+)(?:\/(suspend|resume|lease|rollover|wake|restart|status|archive|unarchive|env\/refresh|ssh-keys|desktop))?)?$/.exec(
|
||||
/^\/v1\/sandboxes(?:\/([A-Za-z0-9_]+)(?:\/(suspend|resume|lease|rollover|wake|restart|status|archive|unarchive|env\/refresh|egress\/hosts|ssh-keys|desktop))?)?$/.exec(
|
||||
url.pathname
|
||||
)
|
||||
if (!match) {
|
||||
@@ -1073,6 +1074,20 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
|
||||
}
|
||||
case 'POST env/refresh':
|
||||
return json({ applied: await refreshSandboxEnv(life, row) })
|
||||
case 'GET egress/hosts':
|
||||
return json({ hosts: egressUserHosts(row) })
|
||||
case 'PUT egress/hosts': {
|
||||
const body = EgressHostsSchema.safeParse(await readJson(request))
|
||||
const hosts = body.success ? normalizeUserHosts(body.data.hosts) : null
|
||||
if (!hosts) {
|
||||
return fail('invalid_request', 400, 'invalid_host')
|
||||
}
|
||||
const next = await setEgressUserHosts(life, row, hosts)
|
||||
if (!next) {
|
||||
return fail('conflict', 409, 'concurrent_transition')
|
||||
}
|
||||
return json({ hosts: egressUserHosts(next) })
|
||||
}
|
||||
case 'POST desktop': {
|
||||
// Runs only the image's fixed entry point; the desktop listens on the VM's loopback and is
|
||||
// reached through the paired runtime's port forward, never a public route.
|
||||
|
||||
@@ -300,7 +300,7 @@ async function boot(
|
||||
// Nothing in the VM uses git or model keys before orcad answers and the desktop attaches.
|
||||
const needsPolicy =
|
||||
!options.policyApplied &&
|
||||
(git || routed.egress_secret_hosts || row.egress_secret_hosts || routed.egress_allow_hosts || row.egress_allow_hosts)
|
||||
(git || routed.egress_secret_hosts || row.egress_secret_hosts || routed.egress_allow_hosts || row.egress_allow_hosts || row.egress_user_hosts)
|
||||
// Members revoked while the VM slept must be gone before orcad accepts its first sign-in.
|
||||
await applyQueuedRevokes(deps.db, deps.provider, row.id, state.session.id, deps.now())
|
||||
const [, connection] = await Promise.all([
|
||||
|
||||
@@ -84,6 +84,7 @@ export async function sessionSecretEnv(
|
||||
return { env: swapped.env, egressSecretHosts: encodeEgressSecretHosts(swapped.targets), egressAllowHosts }
|
||||
}
|
||||
|
||||
export function egressAllowHosts(row: Pick<SandboxRow, 'egress_allow_hosts'>): string[] {
|
||||
return (row.egress_allow_hosts ?? '').split(',').filter(Boolean)
|
||||
/** Provider hosts from the session's secrets plus the workspace's declared tool hosts (MCP). */
|
||||
export function egressAllowHosts(row: Pick<SandboxRow, 'egress_allow_hosts'> & Partial<Pick<SandboxRow, 'egress_user_hosts'>>): string[] {
|
||||
return [...(row.egress_allow_hosts ?? '').split(','), ...(row.egress_user_hosts ?? '').split(',')].filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export type SandboxRow = {
|
||||
egress_secret_hosts: string | null
|
||||
/** Comma list of custom model-provider hosts added to this session's egress allowlist. */
|
||||
egress_allow_hosts: string | null
|
||||
/** Comma list of hosts the workspace's own tools need (synced remote MCP servers); set by the desktop. */
|
||||
egress_user_hosts: string | null
|
||||
retention_warned_at: number | null
|
||||
/** Environment build this sandbox first booted from (null: the golden image). Never changes. */
|
||||
environment_id: string | null
|
||||
@@ -178,7 +180,7 @@ type Patch = Partial<Omit<SandboxRow, 'id' | 'account_id' | 'generation' | 'crea
|
||||
const PATCHABLE = new Set<string>([
|
||||
'status', 'session_id', 'session_started_at', 'session_deadline_at', 'lease_expires_at',
|
||||
'usage_settled_at', 'last_error', 'destroyed_at', 'github_token_expires_at',
|
||||
'egress_secret_hosts', 'egress_allow_hosts', 'repo_url', 'retention_warned_at', 'orcad_endpoint', 'ready_at',
|
||||
'egress_secret_hosts', 'egress_allow_hosts', 'egress_user_hosts', 'repo_url', 'retention_warned_at', 'orcad_endpoint', 'ready_at',
|
||||
'archived_at', 'archive_expires_at'
|
||||
])
|
||||
|
||||
|
||||
@@ -327,3 +327,27 @@ describe('staged egress placeholder rollout', () => {
|
||||
expect(row!.egress_secret_hosts).toBe('ANTHROPIC_AUTH_TOKEN@llm.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace tool hosts (synced remote MCP servers)', () => {
|
||||
it('opens declared hosts on the running sandbox now and on every later boot; refuses wildcards and private names', async () => {
|
||||
const { h, as } = await setup()
|
||||
const mia = await as('mia')
|
||||
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia })
|
||||
const id: string = created.body.sandbox.id
|
||||
const box = () => [...h.vercel.boxes.values()][0]!
|
||||
expect(box().policy.allow['mcp.linear.app']).toBeUndefined()
|
||||
const put = await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia, body: { hosts: ['MCP.linear.app', 'mcp.notion.com.', 'mcp.linear.app'] } })
|
||||
expect(put.body).toEqual({ hosts: ['mcp.linear.app', 'mcp.notion.com'] })
|
||||
expect(box().policy.allow['mcp.linear.app']).toEqual([])
|
||||
expect(box().policy.allow['*']).toBeUndefined()
|
||||
for (const bad of ['*', '*.example.com', '10.0.0.1', 'localhost', 'box.internal', 'http://x.example.com']) {
|
||||
expect((await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia, body: { hosts: [bad] } })).status).toBe(400)
|
||||
}
|
||||
expect((await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: await as('bob'), body: { hosts: ['x.example.com'] } })).status).toBe(404)
|
||||
// Sleep and wake: the hosts come back with the new session's policy.
|
||||
await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia })
|
||||
await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia })
|
||||
expect(box().policy.allow['mcp.notion.com']).toEqual([])
|
||||
expect((await h.call('GET', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia })).body).toEqual({ hosts: ['mcp.linear.app', 'mcp.notion.com'] })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user