Skip to content

Commit 42d8d6f

Browse files
committed
refactor(copilot): tighten manage_sandbox after review
Fixes found auditing the first pass: - updateWorkspaceSandbox treated a whitespace-only name as supplied, so it trimmed to empty, skipped the falsy conflict pre-check, and wrote an unnamed sandbox the UI cannot produce. Name normalization now runs in the operations layer and reuses the contract's own sandboxNameSchema, so the tool path — which has no schema in front of it — cannot accept a name the REST path rejects. - The handler resolved permissions through ensureWorkspaceAccess while the route used getUserEntityPermissions, so the same refusal was worded two ways. Both now use the same primitive and the same SANDBOX_ADMIN_REQUIRED constant, and a DB failure no longer reads as a permission denial. - list returned errorDetail, a 4KB installer log tail per failed build, on every call. errorMessage is the classified summary and is all the tool prompt advertises. - buildSpecUpdate now returns a result instead of throwing, dropping the SandboxDependencyError class, the rethrowing bridge helper, and both try/catch blocks. - Model-supplied dependencies are type-checked once before dispatch rather than per branch; the language error copy derives from SANDBOX_LANGUAGES. - Create logging moved into the operations layer so the tool's creates are logged too, and the route no longer carries a logger. - manage_sandbox was missing from the chat tool-icon map and fell through to the mothership Blimp fallback.
1 parent e5af15d commit 42d8d6f

10 files changed

Lines changed: 322 additions & 124 deletions

File tree

apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface SandboxMutationActor {
2626
*/
2727
export function sandboxFailureResponse(failure: SandboxWriteFailure): NextResponse {
2828
switch (failure.code) {
29+
case 'invalid_name':
30+
return NextResponse.json({ error: failure.message }, { status: 400 })
2931
case 'name_conflict':
3032
return NextResponse.json(
3133
{ error: `A sandbox named "${failure.name}" already exists in this workspace` },
@@ -78,16 +80,14 @@ export async function authorizeSandboxMutation(
7880
}
7981

8082
/** Reads a workspace sandbox list; any member may look, only admins may write. */
81-
export async function authorizeSandboxRead(
82-
workspaceId: string
83-
): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> {
83+
export async function authorizeSandboxRead(workspaceId: string): Promise<NextResponse | null> {
8484
const session = await getSession()
8585
if (!session?.user?.id) {
86-
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
86+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8787
}
8888
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
8989
if (!permission) {
90-
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
90+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9191
}
92-
return { ok: true, userId: session.user.id }
92+
return null
9393
}

apps/sim/app/api/workspaces/[id]/sandboxes/route.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createLogger } from '@sim/logger'
21
import { type NextRequest, NextResponse } from 'next/server'
32
import { createSandboxContract } from '@/lib/api/contracts/sandboxes'
43
import { parseRequest } from '@/lib/api/server'
@@ -15,14 +14,12 @@ import {
1514
sandboxFailureResponse,
1615
} from '@/app/api/workspaces/[id]/sandboxes/authorize'
1716

18-
const logger = createLogger('WorkspaceSandboxesAPI')
19-
2017
export const GET = withRouteHandler(
2118
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
2219
const workspaceId = (await context.params).id
2320

24-
const viewer = await authorizeSandboxRead(workspaceId)
25-
if (!viewer.ok) return viewer.response
21+
const denied = await authorizeSandboxRead(workspaceId)
22+
if (denied) return denied
2623

2724
// The list itself is not plan-gated: a workspace that downgraded must still
2825
// see (and keep executing) what it already built. `entitled` drives whether
@@ -60,11 +57,6 @@ export const POST = withRouteHandler(
6057
})
6158
if (!result.ok) return sandboxFailureResponse(result.failure)
6259

63-
logger.info('Created workspace sandbox', {
64-
workspaceId,
65-
sandboxId: result.sandbox.id,
66-
language,
67-
})
6860
return NextResponse.json({ sandbox: result.sandbox })
6961
}
7062
)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
3939
get_page_contents: Search,
4040
search_library_docs: Library,
4141
manage_mcp_tool: Settings,
42+
manage_sandbox: TerminalWindow,
4243
manage_skill: Asterisk,
4344
user_memory: Database,
4445
function_execute: TerminalWindow,

apps/sim/lib/api/contracts/sandboxes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const dependencyListSchema = z
2828
.array(z.string().max(2000, 'a dependency line is unreasonably long'))
2929
.max(1000, 'too many lines — paste a shorter dependency list')
3030

31-
const sandboxNameSchema = z
31+
export const sandboxNameSchema = z
3232
.string()
3333
.trim()
3434
.min(1, 'Name is required')

apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import type { ExecutionContext } from '@/lib/copilot/request/types'
77

88
const {
9-
ensureWorkspaceAccessMock,
9+
getUserEntityPermissionsMock,
1010
hasWorkspaceSandboxAccessMock,
1111
enforceWorkspaceRateLimitMock,
1212
createWorkspaceSandboxMock,
1313
updateWorkspaceSandboxMock,
1414
deleteWorkspaceSandboxMock,
1515
listWorkspaceSandboxesMock,
1616
} = vi.hoisted(() => ({
17-
ensureWorkspaceAccessMock: vi.fn(),
17+
getUserEntityPermissionsMock: vi.fn(),
1818
hasWorkspaceSandboxAccessMock: vi.fn(),
1919
enforceWorkspaceRateLimitMock: vi.fn(),
2020
createWorkspaceSandboxMock: vi.fn(),
@@ -23,8 +23,8 @@ const {
2323
listWorkspaceSandboxesMock: vi.fn(),
2424
}))
2525

26-
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
27-
ensureWorkspaceAccess: ensureWorkspaceAccessMock,
26+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
27+
getUserEntityPermissions: getUserEntityPermissionsMock,
2828
}))
2929

3030
vi.mock('@/lib/billing/core/subscription', () => ({
@@ -67,7 +67,7 @@ const sandbox = {
6767
describe('manage_sandbox', () => {
6868
beforeEach(() => {
6969
vi.clearAllMocks()
70-
ensureWorkspaceAccessMock.mockResolvedValue({})
70+
getUserEntityPermissionsMock.mockResolvedValue('admin')
7171
hasWorkspaceSandboxAccessMock.mockResolvedValue(true)
7272
enforceWorkspaceRateLimitMock.mockResolvedValue(null)
7373
listWorkspaceSandboxesMock.mockResolvedValue([sandbox])
@@ -84,27 +84,31 @@ describe('manage_sandbox', () => {
8484

8585
it('ignores a model-supplied workspaceId and uses the server context', async () => {
8686
await executeManageSandbox({ operation: 'list', workspaceId: 'other-ws' }, context)
87-
expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'read')
87+
expect(getUserEntityPermissionsMock).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
8888
expect(listWorkspaceSandboxesMock).toHaveBeenCalledWith('ws-1')
8989
})
9090

9191
it('lists with only read access, and does not spend the mutation budget', async () => {
92+
getUserEntityPermissionsMock.mockResolvedValue('read')
93+
9294
const result = await executeManageSandbox({ operation: 'list' }, context)
9395
expect(result.success).toBe(true)
9496
expect(result.output).toMatchObject({ count: 1, strategy: 'prebuilt' })
97+
const [listed] = (result.output as { sandboxes: Record<string, unknown>[] }).sandboxes
98+
expect(listed).not.toHaveProperty('errorDetail')
99+
expect(listed).toMatchObject({ id: 'sb-1', buildStatus: 'pending' })
95100
expect(enforceWorkspaceRateLimitMock).not.toHaveBeenCalled()
96101
expect(hasWorkspaceSandboxAccessMock).not.toHaveBeenCalled()
97102
})
98103

99104
it.each(['add', 'edit', 'delete'])('requires workspace admin to %s', async (operation) => {
100-
ensureWorkspaceAccessMock.mockRejectedValue(new Error('Admin access required'))
105+
getUserEntityPermissionsMock.mockResolvedValue('write')
101106

102107
const result = await executeManageSandbox(
103108
{ operation, name: 'x', language: 'python', sandboxId: 'sb-1' },
104109
context
105110
)
106111

107-
expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'admin')
108112
expect(result.success).toBe(false)
109113
expect(result.error).toBe('Only workspace admins can manage sandboxes')
110114
expect(createWorkspaceSandboxMock).not.toHaveBeenCalled()
@@ -152,7 +156,7 @@ describe('manage_sandbox', () => {
152156
expect(createWorkspaceSandboxMock).toHaveBeenCalledWith({
153157
workspaceId: 'ws-1',
154158
userId: 'user-1',
155-
name: 'data-tools',
159+
name: ' data-tools ',
156160
language: 'python',
157161
dependencies: ['requests'],
158162
})
@@ -167,7 +171,7 @@ describe('manage_sandbox', () => {
167171
)
168172

169173
expect(result.success).toBe(false)
170-
expect(result.error).toContain('javascript')
174+
expect(result.error).toContain('javascript or python')
171175
expect(createWorkspaceSandboxMock).not.toHaveBeenCalled()
172176
})
173177

@@ -234,6 +238,40 @@ describe('manage_sandbox', () => {
234238
expect(result.error).toContain('sb-9')
235239
})
236240

241+
it('forwards a whitespace-only name to the operation, which refuses it', async () => {
242+
await executeManageSandbox({ operation: 'edit', sandboxId: 'sb-1', name: ' ' }, context)
243+
244+
expect(updateWorkspaceSandboxMock).toHaveBeenCalledWith(
245+
expect.objectContaining({ name: ' ' })
246+
)
247+
})
248+
249+
it('rejects a non-string dependency list', async () => {
250+
const result = await executeManageSandbox(
251+
{ operation: 'add', name: 'data-tools', language: 'python', dependencies: [1, 2] },
252+
context
253+
)
254+
255+
expect(result.success).toBe(false)
256+
expect(result.error).toContain('array of strings')
257+
expect(createWorkspaceSandboxMock).not.toHaveBeenCalled()
258+
})
259+
260+
it('surfaces an invalid name refused by the operation', async () => {
261+
createWorkspaceSandboxMock.mockResolvedValue({
262+
ok: false,
263+
failure: { code: 'invalid_name', message: 'Name must be 64 characters or fewer' },
264+
})
265+
266+
const result = await executeManageSandbox(
267+
{ operation: 'add', name: 'x'.repeat(65), language: 'python' },
268+
context
269+
)
270+
271+
expect(result.success).toBe(false)
272+
expect(result.error).toBe('Name must be 64 characters or fewer')
273+
})
274+
237275
it('rejects an unsupported operation', async () => {
238276
const result = await executeManageSandbox({ operation: 'rebuild' }, context)
239277
expect(result.success).toBe(false)

0 commit comments

Comments
 (0)