Skip to content

Commit 7a68976

Browse files
committed
Add user based container
1 parent 51c3a5b commit 7a68976

File tree

7 files changed

+241
-203
lines changed

7 files changed

+241
-203
lines changed

apps/sandbox-container/server/containerHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const MAX_CONTAINERS = 8
1+
export const MAX_CONTAINERS = 20
22
export async function startAndWaitForPort(
33
environment: 'dev' | 'prod' | 'test',
44
container: Container | undefined,

apps/sandbox-container/server/containerManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export class ContainerManager extends DurableObject<Env> {
2828
console.log(id, time, now, now.valueOf() - time.valueOf())
2929

3030
if (now.valueOf() - time.valueOf() > 10 * 60 * 1000) {
31-
const doId = this.env.CONTAINER_MCP_AGENT.idFromString(id)
32-
const stub = this.env.CONTAINER_MCP_AGENT.get(doId)
31+
const doId = this.env.USER_CONTAINER.idFromString(id)
32+
const stub = this.env.USER_CONTAINER.get(doId)
3333
await stub.destroyContainer()
3434
await this.killContainer(id)
3535
}

apps/sandbox-container/server/containerMcp.ts

Lines changed: 29 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@ import { McpAgent } from 'agents/mcp'
22

33
import { CloudflareMCPServer } from '@repo/mcp-common/src/server'
44

5-
import { OPEN_CONTAINER_PORT } from '../shared/consts'
65
import { ExecParams, FilePathParam, FileWrite } from '../shared/schema'
7-
import { MAX_CONTAINERS, proxyFetch, startAndWaitForPort } from './containerHelpers'
8-
import { getContainerManager } from './containerManager'
96
import { BASE_INSTRUCTIONS } from './prompts'
107
import { fileToBase64, stripProtocolFromFilePath } from './utils'
118

12-
import type { FileList } from '../shared/schema'
139
import type { Env } from './context'
14-
import type { Props } from '.'
10+
import type { Props, UserContainer } from '.'
1511

1612
export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
1713
_server: CloudflareMCPServer | undefined
@@ -27,6 +23,11 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
2723
return this._server
2824
}
2925

26+
get userContainer(): DurableObjectStub<UserContainer> {
27+
const userContainer = this.env.USER_CONTAINER.idFromName(this.props.user.id)
28+
return this.env.USER_CONTAINER.get(userContainer)
29+
}
30+
3031
constructor(
3132
public ctx: DurableObjectState,
3233
public env: Env
@@ -35,21 +36,6 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
3536
super(ctx, env)
3637
}
3738

38-
async destroyContainer(): Promise<void> {
39-
await this.ctx.container?.destroy()
40-
}
41-
42-
async killContainer(): Promise<void> {
43-
console.log('Reaping container')
44-
const containerManager = getContainerManager(this.env)
45-
const active = await containerManager.listActive()
46-
if (this.ctx.id.toString() in active) {
47-
console.log('killing container')
48-
await this.destroyContainer()
49-
await containerManager.killContainer(this.ctx.id.toString())
50-
}
51-
}
52-
5339
async init() {
5440
this.props.user.id
5541
this.server = new CloudflareMCPServer({
@@ -64,78 +50,63 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
6450

6551
this.server.tool(
6652
'container_initialize',
67-
'Start or reset the container',
68-
{},
53+
`Start or restart the container.
54+
Use this tool to initialize a container before running any python or node.js code that the user requests ro run.`,
6955
// @ts-ignore
70-
async ({}) => {
56+
async () => {
57+
const userInBlocklist = await this.env.USER_BLOCKLIST.get(this.props.user.id)
58+
if (userInBlocklist) {
59+
return {
60+
content: [{ type: 'text', text: "Blocked from intializing container." }],
61+
}
62+
}
7163
return {
72-
content: [{ type: 'text', text: await this.container_initialize() }],
64+
content: [{ type: 'text', text: await this.userContainer.container_initialize() }],
7365
}
7466
}
7567
)
7668

77-
this.server.tool('container_ping', 'Ping the container for liveliness', {}, async ({}) => {
69+
this.server.tool('container_ping', `Ping the container for liveliness. Use this tool to check if the container is running.`, {}, async ({}) => {
7870
return {
79-
content: [{ type: 'text', text: await this.container_ping() }],
71+
content: [{ type: 'text', text: await this.userContainer.container_ping() }],
8072
}
8173
})
8274
this.server.tool(
8375
'container_exec',
84-
'Run a command in a container and return the results from stdout',
76+
'Run a command in a container and return the results from stdout. If necessary, set a timeout. To debug, stream back standard error.',
8577
{ args: ExecParams },
8678
async ({ args }) => {
8779
return {
88-
content: [{ type: 'text', text: await this.container_exec(args) }],
80+
content: [{ type: 'text', text: await this.userContainer.container_exec(args) }],
8981
}
9082
}
9183
)
9284
this.server.tool(
9385
'container_file_delete',
94-
'Delete file and its contents',
86+
'Delete file in the working directory',
9587
{ args: FilePathParam },
9688
async ({ args }) => {
9789
const path = await stripProtocolFromFilePath(args.path)
98-
const deleted = await this.container_file_delete(path)
90+
const deleted = await this.userContainer.container_file_delete(path)
9991
return {
10092
content: [{ type: 'text', text: `File deleted: ${deleted}.` }],
10193
}
10294
}
10395
)
10496
this.server.tool(
10597
'container_file_write',
106-
'Create a new file with the provided contents, overwriting the file if it already exists',
98+
'Create a new file with the provided contents in the working direcotry, overwriting the file if it already exists',
10799
{ args: FileWrite },
108100
async ({ args }) => {
109101
args.path = await stripProtocolFromFilePath(args.path)
110102
return {
111-
content: [{ type: 'text', text: await this.container_file_write(args) }],
103+
content: [{ type: 'text', text: await this.userContainer.container_file_write(args) }],
112104
}
113105
}
114106
)
115-
this.server.tool('container_files_list', 'List working directory file tree', {}, async ({}) => {
116-
// This approach relies on resources, which aren't handled well by Claude right now. Until that's sorted, we can just use file read, since it lists all files in a directory if a directory is passed to it.
117-
//const files = await this.container_ls()
118-
119-
// const resources: {
120-
// type: 'resource'
121-
// resource: { uri: string; text: string; mimeType: string }
122-
// }[] = files.resources.map((r) => {
123-
// return {
124-
// type: 'resource',
125-
// resource: {
126-
// uri: r.uri,
127-
// text: r.uri,
128-
// mimeType: 'text/plain',
129-
// },
130-
// }
131-
// })
132-
133-
// return {
134-
// content: resources,
135-
// }
136-
107+
this.server.tool('container_files_list', 'List working directory file tree. This just reads the contents of the current working directory', {}, async ({}) => {
137108
// Begin workaround using container read rather than ls:
138-
const { blob, mimeType } = await this.container_file_read('.')
109+
const { blob, mimeType } = await this.userContainer.container_file_read('.')
139110
return {
140111
content: [
141112
{
@@ -155,7 +126,7 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
155126
{ args: FilePathParam },
156127
async ({ args }) => {
157128
const path = await stripProtocolFromFilePath(args.path)
158-
const { blob, mimeType } = await this.container_file_read(path)
129+
const { blob, mimeType } = await this.userContainer.container_file_read(path)
159130

160131
if (mimeType && mimeType.startsWith('text')) {
161132
return {
@@ -176,6 +147,8 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
176147
{
177148
type: 'resource',
178149
resource: {
150+
// for some reason the RPC type for Blob is not exactly the same as the regular Blob type
151+
// @ts-ignore
179152
blob: await fileToBase64(blob),
180153
uri: `file://${path}`,
181154
mimeType: mimeType,
@@ -187,134 +160,4 @@ export class ContainerMcpAgent extends McpAgent<Env, {}, Props> {
187160
}
188161
)
189162
}
190-
191-
async container_initialize(): Promise<string> {
192-
// kill container
193-
await this.killContainer()
194-
195-
// try to cleanup cleanup old containers
196-
const containerManager = getContainerManager(this.env)
197-
198-
if ((await containerManager.listActive()).length >= MAX_CONTAINERS) {
199-
await containerManager.tryKillOldContainers()
200-
if ((await containerManager.listActive()).length >= MAX_CONTAINERS) {
201-
throw new Error(
202-
`Unable to reap enough containers. There are ${MAX_CONTAINERS} active container sandboxes, please wait`
203-
)
204-
}
205-
}
206-
207-
// start container
208-
let startedContainer = false
209-
await this.ctx.blockConcurrencyWhile(async () => {
210-
startedContainer = await startAndWaitForPort(
211-
this.env.ENVIRONMENT,
212-
this.ctx.container,
213-
OPEN_CONTAINER_PORT
214-
)
215-
})
216-
if (!startedContainer) {
217-
throw new Error('Failed to start container')
218-
}
219-
220-
// track and manage lifecycle
221-
containerManager.trackContainer(this.ctx.id.toString())
222-
223-
return `Created new container`
224-
}
225-
226-
async container_ping(): Promise<string> {
227-
const res = await proxyFetch(
228-
this.env.ENVIRONMENT,
229-
this.ctx.container,
230-
new Request(`http://host:${OPEN_CONTAINER_PORT}/ping`),
231-
OPEN_CONTAINER_PORT
232-
)
233-
if (!res || !res.ok) {
234-
throw new Error(`Request to container failed: ${await res.text()}`)
235-
}
236-
return await res.text()
237-
}
238-
239-
async container_exec(params: ExecParams): Promise<string> {
240-
const res = await proxyFetch(
241-
this.env.ENVIRONMENT,
242-
this.ctx.container,
243-
new Request(`http://host:${OPEN_CONTAINER_PORT}/exec`, {
244-
method: 'POST',
245-
body: JSON.stringify(params),
246-
headers: {
247-
'content-type': 'application/json',
248-
},
249-
}),
250-
OPEN_CONTAINER_PORT
251-
)
252-
if (!res || !res.ok) {
253-
throw new Error(`Request to container failed: ${await res.text()}`)
254-
}
255-
const txt = await res.text()
256-
return txt
257-
}
258-
259-
async container_ls(): Promise<FileList> {
260-
const res = await proxyFetch(
261-
this.env.ENVIRONMENT,
262-
this.ctx.container,
263-
new Request(`http://host:${OPEN_CONTAINER_PORT}/files/ls`),
264-
OPEN_CONTAINER_PORT
265-
)
266-
if (!res || !res.ok) {
267-
throw new Error(`Request to container failed: ${await res.text()}`)
268-
}
269-
const json = (await res.json()) as FileList
270-
return json
271-
}
272-
273-
async container_file_delete(filePath: string): Promise<boolean> {
274-
const res = await proxyFetch(
275-
this.env.ENVIRONMENT,
276-
this.ctx.container,
277-
new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents/${filePath}`, {
278-
method: 'DELETE',
279-
}),
280-
OPEN_CONTAINER_PORT
281-
)
282-
return res.ok
283-
}
284-
async container_file_read(
285-
filePath: string
286-
): Promise<{ blob: Blob; mimeType: string | undefined }> {
287-
const res = await proxyFetch(
288-
this.env.ENVIRONMENT,
289-
this.ctx.container,
290-
new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents/${filePath}`),
291-
OPEN_CONTAINER_PORT
292-
)
293-
if (!res || !res.ok) {
294-
throw new Error(`Request to container failed: ${await res.text()}`)
295-
}
296-
return {
297-
blob: await res.blob(),
298-
mimeType: res.headers.get('Content-Type') ?? undefined,
299-
}
300-
}
301-
302-
async container_file_write(file: FileWrite): Promise<string> {
303-
const res = await proxyFetch(
304-
this.env.ENVIRONMENT,
305-
this.ctx.container,
306-
new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents`, {
307-
method: 'POST',
308-
body: JSON.stringify(file),
309-
headers: {
310-
'content-type': 'application/json',
311-
},
312-
}),
313-
OPEN_CONTAINER_PORT
314-
)
315-
if (!res || !res.ok) {
316-
throw new Error(`Request to container failed: ${await res.text()}`)
317-
}
318-
return `Wrote file: ${file.path}`
319-
}
320163
}

apps/sandbox-container/server/context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ContainerManager, ContainerMcpAgent } from './index'
1+
import type { ContainerManager, ContainerMcpAgent, UserContainer } from './index'
22

33
export interface Env {
44
OAUTH_KV: KVNamespace
@@ -10,6 +10,8 @@ export interface Env {
1010
OPENAI_API_KEY: string
1111
CONTAINER_MCP_AGENT: DurableObjectNamespace<ContainerMcpAgent>
1212
CONTAINER_MANAGER: DurableObjectNamespace<ContainerManager>
13+
USER_CONTAINER: DurableObjectNamespace<UserContainer>
14+
USER_BLOCKLIST: KVNamespace
1315
MCP_METRICS: AnalyticsEngineDataset
1416
AI: Ai
1517
DEV_DISABLE_OAUTH: string

apps/sandbox-container/server/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import { ContainerMcpAgent } from './containerMcp'
1616
import type { McpAgent } from 'agents/mcp'
1717
import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler'
1818
import type { Env } from './context'
19+
import { UserContainer } from './userContainer'
1920

20-
export { ContainerManager, ContainerMcpAgent }
21+
export { ContainerManager, ContainerMcpAgent, UserContainer }
2122

2223
const env = getEnv<Env>()
2324

0 commit comments

Comments
 (0)