Skip to content

Commit 3c8d0e2

Browse files
committed
Add OpenCode container variant and improve startup reliability
- Add -opencode Docker image variant with OpenCode CLI pre-installed - Handle concurrent startup attempts gracefully with retry logic - Add error handling in proxyToOpencode with proper error responses - Add OPENCODE_STARTUP_FAILED error code to shared error codes - Add logging throughout OpenCode server lifecycle - Use exact command matching for process detection - Add E2E tests for OpenCode CLI availability and server lifecycle - Update CI workflows to build -opencode image variant
1 parent 955eeac commit 3c8d0e2

File tree

15 files changed

+350
-38
lines changed

15 files changed

+350
-38
lines changed

.github/workflows/pkg-pr-new.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ jobs:
109109
build-args: |
110110
SANDBOX_VERSION=${{ steps.package-version.outputs.version }}
111111
112+
- name: Build and push Docker image (opencode)
113+
uses: docker/build-push-action@v6
114+
with:
115+
context: .
116+
file: packages/sandbox/Dockerfile
117+
target: opencode
118+
platforms: linux/amd64
119+
push: true
120+
tags: cloudflare/sandbox:${{ steps.package-version.outputs.version }}-opencode
121+
cache-from: |
122+
type=gha,scope=preview-pr-${{ github.event.pull_request.number }}-opencode
123+
type=gha,scope=release-opencode
124+
cache-to: type=gha,mode=max,scope=preview-pr-${{ github.event.pull_request.number }}-opencode
125+
build-args: |
126+
SANDBOX_VERSION=${{ steps.package-version.outputs.version }}
127+
112128
- name: Publish to pkg.pr.new
113129
run: npx pkg-pr-new publish './packages/sandbox'
114130

@@ -119,7 +135,8 @@ jobs:
119135
const version = '${{ steps.package-version.outputs.version }}';
120136
const defaultTag = `cloudflare/sandbox:${version}`;
121137
const pythonTag = `cloudflare/sandbox:${version}-python`;
122-
const body = `### 🐳 Docker Images Published\n\n**Default (no Python):**\n\`\`\`dockerfile\nFROM ${defaultTag}\n\`\`\`\n\n**With Python:**\n\`\`\`dockerfile\nFROM ${pythonTag}\n\`\`\`\n\n**Version:** \`${version}\`\n\nUse the \`-python\` variant if you need Python code execution.`;
138+
const opencodeTag = `cloudflare/sandbox:${version}-opencode`;
139+
const body = `### 🐳 Docker Images Published\n\n**Default (no Python):**\n\`\`\`dockerfile\nFROM ${defaultTag}\n\`\`\`\n\n**With Python:**\n\`\`\`dockerfile\nFROM ${pythonTag}\n\`\`\`\n\n**With OpenCode:**\n\`\`\`dockerfile\nFROM ${opencodeTag}\n\`\`\`\n\n**Version:** \`${version}\`\n\nUse the \`-python\` variant for Python code execution, or \`-opencode\` for the OpenCode AI coding agent.`;
123140
124141
// Find existing comment
125142
const { data: comments } = await github.rest.issues.listComments({

.github/workflows/pullrequest.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,18 @@ jobs:
116116
- name: Set up Docker Buildx
117117
uses: docker/setup-buildx-action@v3
118118

119-
- name: Build test worker Docker images (base + python)
119+
- name: Build test worker Docker images (base + python + opencode)
120120
run: |
121121
VERSION=${{ needs.unit-tests.outputs.version || '0.0.0' }}
122-
# Build base image (no Python) - used by SandboxBase binding
122+
# Build base image (no Python) - used by Sandbox binding
123123
docker build -f packages/sandbox/Dockerfile --target default --platform linux/amd64 \
124124
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION .
125-
# Build python image - used by Sandbox binding
125+
# Build python image - used by SandboxPython binding
126126
docker build -f packages/sandbox/Dockerfile --target python --platform linux/amd64 \
127127
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-python .
128+
# Build opencode image - used by SandboxOpencode binding
129+
docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \
130+
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode .
128131
129132
# Deploy test worker using official Cloudflare action
130133
- name: Deploy test worker

.github/workflows/release.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ jobs:
108108
- name: Set up Docker Buildx
109109
uses: docker/setup-buildx-action@v3
110110

111-
- name: Build test worker Docker images (base + python)
111+
- name: Build test worker Docker images (base + python + opencode)
112112
run: |
113113
VERSION=${{ needs.unit-tests.outputs.version }}
114114
# Build base image (no Python) - used by Sandbox binding
@@ -117,6 +117,9 @@ jobs:
117117
# Build python image - used by SandboxPython binding
118118
docker build -f packages/sandbox/Dockerfile --target python --platform linux/amd64 \
119119
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-python .
120+
# Build opencode image - used by SandboxOpencode binding
121+
docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \
122+
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode .
120123
121124
- name: Deploy test worker
122125
uses: cloudflare/wrangler-action@v3
@@ -213,6 +216,20 @@ jobs:
213216
build-args: |
214217
SANDBOX_VERSION=${{ needs.unit-tests.outputs.version }}
215218
219+
- name: Build and push Docker image (opencode)
220+
uses: docker/build-push-action@v6
221+
with:
222+
context: .
223+
file: packages/sandbox/Dockerfile
224+
target: opencode
225+
platforms: linux/amd64
226+
push: true
227+
tags: cloudflare/sandbox:${{ needs.unit-tests.outputs.version }}-opencode
228+
cache-from: type=gha,scope=release-opencode
229+
cache-to: type=gha,mode=max,scope=release-opencode
230+
build-args: |
231+
SANDBOX_VERSION=${{ needs.unit-tests.outputs.version }}
232+
216233
- id: changesets
217234
uses: changesets/action@v1
218235
with:

examples/opencode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"typecheck": "tsc --noEmit"
1313
},
1414
"dependencies": {
15-
"@opencode-ai/sdk": "^1.0.0"
15+
"@opencode-ai/sdk": "^1.0.133"
1616
},
1717
"devDependencies": {
1818
"@cloudflare/sandbox": "*",

packages/sandbox/Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,27 @@ ENV JAVASCRIPT_POOL_MIN_SIZE=3
187187
ENV TYPESCRIPT_POOL_MIN_SIZE=3
188188

189189
CMD ["/container-server/startup.sh"]
190+
191+
# ============================================================================
192+
# Stage 5c: OpenCode image - with OpenCode CLI for AI coding agent
193+
# ============================================================================
194+
FROM runtime-base AS opencode
195+
196+
# Add opencode to PATH
197+
ENV PATH="/root/.opencode/bin:${PATH}"
198+
199+
# Install OpenCode CLI
200+
RUN curl -fsSL https://opencode.ai/install -o /tmp/install-opencode.sh \
201+
&& bash /tmp/install-opencode.sh \
202+
&& rm /tmp/install-opencode.sh \
203+
&& opencode --version
204+
205+
# Disable Python pool (Python not available in this image)
206+
ENV PYTHON_POOL_MIN_SIZE=0
207+
ENV JAVASCRIPT_POOL_MIN_SIZE=3
208+
ENV TYPESCRIPT_POOL_MIN_SIZE=3
209+
210+
# Expose OpenCode server port (in addition to 3000 from runtime-base)
211+
EXPOSE 4096
212+
213+
CMD ["/container-server/startup.sh"]

packages/sandbox/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
},
1818
"peerDependencies": {
1919
"@openai/agents": "^0.3.3",
20-
"@opencode-ai/sdk": "^1.0.0"
20+
"@opencode-ai/sdk": "^1.0.133"
2121
},
2222
"peerDependenciesMeta": {
2323
"@openai/agents": {

packages/sandbox/src/opencode/opencode.ts

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Process } from '@repo/shared';
1+
import type { Logger, Process } from '@repo/shared';
22
import type { Sandbox } from '../sandbox';
33
import { createSandboxFetch } from './fetch';
44
import type {
@@ -34,14 +34,15 @@ async function ensureSdkLoaded(): Promise<void> {
3434
* Returns the process if found and still active, null otherwise.
3535
*/
3636
async function findExistingOpencodeProcess(
37-
sandbox: Sandbox<any>,
37+
sandbox: Sandbox<unknown>,
3838
port: number
3939
): Promise<Process | null> {
4040
const processes = await sandbox.listProcesses();
41-
const command = `opencode serve --port ${port}`;
41+
// Use exact command match to avoid matching unrelated processes
42+
const expectedCommand = `opencode serve --port ${port} --hostname 0.0.0.0`;
4243

4344
for (const proc of processes) {
44-
if (proc.command.includes(command)) {
45+
if (proc.command === expectedCommand) {
4546
if (proc.status === 'starting' || proc.status === 'running') {
4647
return proc;
4748
}
@@ -54,37 +55,84 @@ async function findExistingOpencodeProcess(
5455
/**
5556
* Ensures OpenCode server is running in the container.
5657
* Reuses existing process if one is already running on the specified port.
58+
* Handles concurrent startup attempts gracefully by retrying on failure.
5759
* Returns the process handle.
5860
*/
5961
async function ensureOpencodeServer(
60-
sandbox: Sandbox<any>,
62+
sandbox: Sandbox<unknown>,
6163
port: number,
62-
config?: Record<string, unknown>
64+
config?: Record<string, unknown>,
65+
logger?: Logger
6366
): Promise<Process> {
6467
// Check if OpenCode is already running on this port
65-
let process = await findExistingOpencodeProcess(sandbox, port);
66-
67-
if (process) {
68+
const existingProcess = await findExistingOpencodeProcess(sandbox, port);
69+
if (existingProcess) {
70+
logger?.debug('Reusing existing OpenCode process', {
71+
port,
72+
processId: existingProcess.id
73+
});
6874
// Reuse existing process - wait for it to be ready if still starting
69-
if (process.status === 'starting') {
75+
if (existingProcess.status === 'starting') {
7076
try {
71-
await process.waitForPort(port, {
77+
await existingProcess.waitForPort(port, {
7278
mode: 'http',
7379
path: '/',
7480
timeout: 60_000
7581
});
7682
} catch (e) {
77-
const logs = await process.getLogs();
83+
const logs = await existingProcess.getLogs();
7884
throw new OpencodeStartupError(
7985
`OpenCode server failed to start. Stderr: ${logs.stderr || '(empty)'}`,
8086
{ cause: e }
8187
);
8288
}
8389
}
84-
return process;
90+
return existingProcess;
91+
}
92+
93+
// Try to start a new OpenCode server
94+
try {
95+
return await startOpencodeServer(sandbox, port, config, logger);
96+
} catch (startupError) {
97+
// Startup failed - check if another concurrent request started the server
98+
// This handles the race condition where multiple requests try to start simultaneously
99+
logger?.debug('Startup failed, checking for concurrent server start', {
100+
port
101+
});
102+
103+
const retryProcess = await findExistingOpencodeProcess(sandbox, port);
104+
if (retryProcess) {
105+
logger?.debug('Found server started by concurrent request', {
106+
port,
107+
processId: retryProcess.id
108+
});
109+
// Wait for the concurrent server to be ready
110+
if (retryProcess.status === 'starting') {
111+
await retryProcess.waitForPort(port, {
112+
mode: 'http',
113+
path: '/',
114+
timeout: 60_000
115+
});
116+
}
117+
return retryProcess;
118+
}
119+
120+
// No concurrent server found - the failure was genuine
121+
throw startupError;
85122
}
123+
}
124+
125+
/**
126+
* Internal function to start a new OpenCode server process.
127+
*/
128+
async function startOpencodeServer(
129+
sandbox: Sandbox<unknown>,
130+
port: number,
131+
config?: Record<string, unknown>,
132+
logger?: Logger
133+
): Promise<Process> {
134+
logger?.info('Starting OpenCode server', { port });
86135

87-
// Start new OpenCode server
88136
// Pass config via OPENCODE_CONFIG_CONTENT and also extract API keys to env vars
89137
// because OpenCode's provider auth looks for env vars like ANTHROPIC_API_KEY
90138
const env: Record<string, string> = {};
@@ -107,7 +155,7 @@ async function ensureOpencodeServer(
107155
}
108156
}
109157

110-
process = await sandbox.startProcess(
158+
const process = await sandbox.startProcess(
111159
`opencode serve --port ${port} --hostname 0.0.0.0`,
112160
{ env: Object.keys(env).length > 0 ? env : undefined }
113161
);
@@ -119,8 +167,13 @@ async function ensureOpencodeServer(
119167
path: '/',
120168
timeout: 60_000
121169
});
170+
logger?.debug('OpenCode server started', { operation: 'opencode.start' });
122171
} catch (e) {
123172
const logs = await process.getLogs();
173+
const error = e instanceof Error ? e : undefined;
174+
logger?.error('OpenCode server failed to start', error, {
175+
operation: 'opencode.start'
176+
});
124177
throw new OpencodeStartupError(
125178
`OpenCode server failed to start. Stderr: ${logs.stderr || '(empty)'}`,
126179
{ cause: e }
@@ -158,13 +211,18 @@ async function ensureOpencodeServer(
158211
* ```
159212
*/
160213
export async function createOpencode<TClient = unknown>(
161-
sandbox: Sandbox<any>,
214+
sandbox: Sandbox<unknown>,
162215
options?: OpencodeOptions
163216
): Promise<OpencodeResult<TClient>> {
164217
await ensureSdkLoaded();
165218

166219
const port = options?.port ?? DEFAULT_PORT;
167-
const process = await ensureOpencodeServer(sandbox, port, options?.config);
220+
const process = await ensureOpencodeServer(
221+
sandbox,
222+
port,
223+
options?.config,
224+
options?.logger
225+
);
168226

169227
// Create SDK client with Sandbox transport
170228
// Cast from unknown - SDK is optional peer dependency loaded dynamically
@@ -220,7 +278,7 @@ export async function createOpencode<TClient = unknown>(
220278
*/
221279
export async function proxyToOpencode(
222280
request: Request,
223-
sandbox: Sandbox<any>,
281+
sandbox: Sandbox<unknown>,
224282
options?: ProxyToOpencodeOptions
225283
): Promise<Response> {
226284
const url = new URL(request.url);
@@ -233,17 +291,32 @@ export async function proxyToOpencode(
233291
request.method === 'GET' &&
234292
request.headers.get('Accept')?.includes('text/html');
235293

236-
if (
237-
url.hostname === 'localhost' &&
238-
!url.searchParams.has('url') &&
239-
isNavigation
240-
) {
294+
// Also handle 127.0.0.1 which OpenCode may use
295+
const isLocalhost =
296+
url.hostname === 'localhost' || url.hostname === '127.0.0.1';
297+
298+
if (isLocalhost && !url.searchParams.has('url') && isNavigation) {
241299
url.searchParams.set('url', url.origin);
242300
return Response.redirect(url.toString(), 302);
243301
}
244302

245303
// Ensure OpenCode server is running
246-
await ensureOpencodeServer(sandbox, port, options?.config);
304+
try {
305+
await ensureOpencodeServer(sandbox, port, options?.config, options?.logger);
306+
} catch (err) {
307+
const error = err instanceof Error ? err : undefined;
308+
options?.logger?.error('Failed to start OpenCode server', error, {
309+
operation: 'opencode.proxy'
310+
});
311+
const message =
312+
err instanceof OpencodeStartupError
313+
? err.message
314+
: 'Failed to start OpenCode server';
315+
return new Response(JSON.stringify({ error: message }), {
316+
status: 503,
317+
headers: { 'Content-Type': 'application/json' }
318+
});
319+
}
247320

248321
// Proxy the request to OpenCode
249322
return sandbox.containerFetch(request, port);

packages/sandbox/src/opencode/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// packages/sandbox/src/opencode/types.ts
2-
import type { Process } from '@repo/shared';
2+
import type { Logger, Process } from '@repo/shared';
3+
import { ErrorCode } from '@repo/shared/errors';
34

45
/**
56
* Configuration options for starting OpenCode server
@@ -10,6 +11,8 @@ export interface OpencodeOptions {
1011
port?: number;
1112
/** OpenCode configuration - passed via OPENCODE_CONFIG_CONTENT env var */
1213
config?: Record<string, unknown>;
14+
/** Logger for debug output */
15+
logger?: Logger;
1316
}
1417

1518
/**
@@ -45,12 +48,16 @@ export interface ProxyToOpencodeOptions {
4548
port?: number;
4649
/** OpenCode configuration - passed via OPENCODE_CONFIG_CONTENT env var */
4750
config?: Record<string, unknown>;
51+
/** Logger for debug output */
52+
logger?: Logger;
4853
}
4954

5055
/**
5156
* Error thrown when OpenCode server fails to start
5257
*/
5358
export class OpencodeStartupError extends Error {
59+
public readonly code = ErrorCode.OPENCODE_STARTUP_FAILED;
60+
5461
constructor(message: string, options?: ErrorOptions) {
5562
super(message, options);
5663
this.name = 'OpencodeStartupError';

packages/shared/src/errors/codes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export const ErrorCode = {
9797
// Code Interpreter Errors (501) - Feature not available in image variant
9898
PYTHON_NOT_AVAILABLE: 'PYTHON_NOT_AVAILABLE',
9999

100+
// OpenCode Errors (503)
101+
OPENCODE_STARTUP_FAILED: 'OPENCODE_STARTUP_FAILED',
102+
100103
// Process Readiness Errors (408/500)
101104
PROCESS_READY_TIMEOUT: 'PROCESS_READY_TIMEOUT',
102105
PROCESS_EXITED_BEFORE_READY: 'PROCESS_EXITED_BEFORE_READY',

0 commit comments

Comments
 (0)