1- import type { Logger , Process } from '@repo/shared' ;
1+ import type { Config } from '@opencode-ai/sdk' ;
2+ import { createLogger , type Logger , type Process } from '@repo/shared' ;
23import type { Sandbox } from '../sandbox' ;
34import { createSandboxFetch } from './fetch' ;
45import type {
@@ -9,7 +10,14 @@ import type {
910} from './types' ;
1011import { OpencodeStartupError } from './types' ;
1112
13+ // Lazy logger creation to avoid global scope restrictions in Workers
14+ function getLogger ( ) : Logger {
15+ return createLogger ( { component : 'sandbox-do' , operation : 'opencode' } ) ;
16+ }
17+
1218const DEFAULT_PORT = 4096 ;
19+ const OPENCODE_COMMAND = ( port : number ) =>
20+ `opencode serve --port ${ port } --hostname 0.0.0.0` ;
1321
1422// Dynamic import to handle peer dependency
1523// Using unknown since SDK is optional peer dep - cast at usage site
@@ -38,8 +46,7 @@ async function findExistingOpencodeProcess(
3846 port : number
3947) : Promise < Process | null > {
4048 const processes = await sandbox . listProcesses ( ) ;
41- // Use exact command match to avoid matching unrelated processes
42- const expectedCommand = `opencode serve --port ${ port } --hostname 0.0.0.0` ;
49+ const expectedCommand = OPENCODE_COMMAND ( port ) ;
4350
4451 for ( const proc of processes ) {
4552 if ( proc . command === expectedCommand ) {
@@ -61,18 +68,17 @@ async function findExistingOpencodeProcess(
6168async function ensureOpencodeServer (
6269 sandbox : Sandbox < unknown > ,
6370 port : number ,
64- config ?: Record < string , unknown > ,
65- logger ?: Logger
71+ config ?: Config
6672) : Promise < Process > {
6773 // Check if OpenCode is already running on this port
6874 const existingProcess = await findExistingOpencodeProcess ( sandbox , port ) ;
6975 if ( existingProcess ) {
70- logger ?. debug ( 'Reusing existing OpenCode process' , {
71- port,
72- processId : existingProcess . id
73- } ) ;
7476 // Reuse existing process - wait for it to be ready if still starting
7577 if ( existingProcess . status === 'starting' ) {
78+ getLogger ( ) . debug ( 'Found starting OpenCode process, waiting for ready' , {
79+ port,
80+ processId : existingProcess . id
81+ } ) ;
7682 try {
7783 await existingProcess . waitForPort ( port , {
7884 mode : 'http' ,
@@ -83,29 +89,33 @@ async function ensureOpencodeServer(
8389 const logs = await existingProcess . getLogs ( ) ;
8490 throw new OpencodeStartupError (
8591 `OpenCode server failed to start. Stderr: ${ logs . stderr || '(empty)' } ` ,
92+ { port, stderr : logs . stderr , command : existingProcess . command } ,
8693 { cause : e }
8794 ) ;
8895 }
8996 }
97+ getLogger ( ) . debug ( 'Reusing existing OpenCode process' , {
98+ port,
99+ processId : existingProcess . id
100+ } ) ;
90101 return existingProcess ;
91102 }
92103
93104 // Try to start a new OpenCode server
94105 try {
95- return await startOpencodeServer ( sandbox , port , config , logger ) ;
106+ return await startOpencodeServer ( sandbox , port , config ) ;
96107 } catch ( startupError ) {
97108 // Startup failed - check if another concurrent request started the server
98109 // 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-
103110 const retryProcess = await findExistingOpencodeProcess ( sandbox , port ) ;
104111 if ( retryProcess ) {
105- logger ?. debug ( 'Found server started by concurrent request' , {
106- port,
107- processId : retryProcess . id
108- } ) ;
112+ getLogger ( ) . debug (
113+ 'Startup failed but found concurrent process, reusing' ,
114+ {
115+ port,
116+ processId : retryProcess . id
117+ }
118+ ) ;
109119 // Wait for the concurrent server to be ready
110120 if ( retryProcess . status === 'starting' ) {
111121 await retryProcess . waitForPort ( port , {
@@ -128,10 +138,9 @@ async function ensureOpencodeServer(
128138async function startOpencodeServer (
129139 sandbox : Sandbox < unknown > ,
130140 port : number ,
131- config ?: Record < string , unknown > ,
132- logger ?: Logger
141+ config ?: Config
133142) : Promise < Process > {
134- logger ? .info ( 'Starting OpenCode server' , { port } ) ;
143+ getLogger ( ) . info ( 'Starting OpenCode server' , { port } ) ;
135144
136145 // Pass config via OPENCODE_CONFIG_CONTENT and also extract API keys to env vars
137146 // because OpenCode's provider auth looks for env vars like ANTHROPIC_API_KEY
@@ -140,25 +149,31 @@ async function startOpencodeServer(
140149 if ( config ) {
141150 env . OPENCODE_CONFIG_CONTENT = JSON . stringify ( config ) ;
142151
143- // Extract API keys from config and set as env vars
144- const providers = (
145- config as { provider ?: Record < string , { apiKey ?: string } > }
146- ) . provider ;
147- if ( providers ) {
148- for ( const [ providerId , providerConfig ] of Object . entries ( providers ) ) {
149- if ( providerConfig ?. apiKey ) {
150- // Convert provider ID to env var name (e.g., anthropic -> ANTHROPIC_API_KEY)
152+ // Extract API keys from provider config
153+ // Support both options.apiKey (official type) and legacy top-level apiKey
154+ if ( config . provider ) {
155+ for ( const [ providerId , providerConfig ] of Object . entries (
156+ config . provider
157+ ) ) {
158+ // Try options.apiKey first (official Config type)
159+ let apiKey = providerConfig ?. options ?. apiKey ;
160+ // Fall back to top-level apiKey for convenience
161+ if ( ! apiKey ) {
162+ apiKey = ( providerConfig as Record < string , unknown > | undefined )
163+ ?. apiKey as string | undefined ;
164+ }
165+ if ( typeof apiKey === 'string' ) {
151166 const envVar = `${ providerId . toUpperCase ( ) } _API_KEY` ;
152- env [ envVar ] = providerConfig . apiKey ;
167+ env [ envVar ] = apiKey ;
153168 }
154169 }
155170 }
156171 }
157172
158- const process = await sandbox . startProcess (
159- `opencode serve --port ${ port } --hostname 0.0.0.0` ,
160- { env : Object . keys ( env ) . length > 0 ? env : undefined }
161- ) ;
173+ const command = OPENCODE_COMMAND ( port ) ;
174+ const process = await sandbox . startProcess ( command , {
175+ env : Object . keys ( env ) . length > 0 ? env : undefined
176+ } ) ;
162177
163178 // Wait for server to be ready
164179 try {
@@ -167,15 +182,20 @@ async function startOpencodeServer(
167182 path : '/' ,
168183 timeout : 60_000
169184 } ) ;
170- logger ?. debug ( 'OpenCode server started' , { operation : 'opencode.start' } ) ;
185+ getLogger ( ) . info ( 'OpenCode server started successfully' , {
186+ port,
187+ processId : process . id
188+ } ) ;
171189 } catch ( e ) {
172190 const logs = await process . getLogs ( ) ;
173191 const error = e instanceof Error ? e : undefined ;
174- logger ?. error ( 'OpenCode server failed to start' , error , {
175- operation : 'opencode.start'
192+ getLogger ( ) . error ( 'OpenCode server failed to start' , error , {
193+ port,
194+ stderr : logs . stderr
176195 } ) ;
177196 throw new OpencodeStartupError (
178197 `OpenCode server failed to start. Stderr: ${ logs . stderr || '(empty)' } ` ,
198+ { port, stderr : logs . stderr , command } ,
179199 { cause : e }
180200 ) ;
181201 }
@@ -204,7 +224,7 @@ async function startOpencodeServer(
204224 *
205225 * const sandbox = getSandbox(env.Sandbox, 'my-agent')
206226 * const { client, server } = await createOpencode(sandbox, {
207- * config: { provider: { anthropic: { apiKey: env.ANTHROPIC_KEY } } }
227+ * config: { provider: { anthropic: { options: { apiKey: env.ANTHROPIC_KEY } } } }
208228 * })
209229 *
210230 * const session = await client.session.create({ body: { title: 'Task' } })
@@ -217,12 +237,7 @@ export async function createOpencode<TClient = unknown>(
217237 await ensureSdkLoaded ( ) ;
218238
219239 const port = options ?. port ?? DEFAULT_PORT ;
220- const process = await ensureOpencodeServer (
221- sandbox ,
222- port ,
223- options ?. config ,
224- options ?. logger
225- ) ;
240+ const process = await ensureOpencodeServer ( sandbox , port , options ?. config ) ;
226241
227242 // Create SDK client with Sandbox transport
228243 // Cast from unknown - SDK is optional peer dependency loaded dynamically
@@ -270,7 +285,7 @@ export async function createOpencode<TClient = unknown>(
270285 * async fetch(request: Request, env: Env): Promise<Response> {
271286 * const sandbox = getSandbox(env.Sandbox, 'opencode')
272287 * return proxyToOpencode(request, sandbox, {
273- * config: { provider: { anthropic: { apiKey: env.ANTHROPIC_API_KEY } } }
288+ * config: { provider: { anthropic: { options: { apiKey: env.ANTHROPIC_API_KEY } } } }
274289 * })
275290 * }
276291 * }
@@ -295,18 +310,20 @@ export async function proxyToOpencode(
295310 const isLocalhost =
296311 url . hostname === 'localhost' || url . hostname === '127.0.0.1' ;
297312
313+ // Safe redirect: Only redirects localhost requests to themselves with ?url= param.
314+ // The isLocalhost check ensures we cannot redirect to external domains.
298315 if ( isLocalhost && ! url . searchParams . has ( 'url' ) && isNavigation ) {
299316 url . searchParams . set ( 'url' , url . origin ) ;
300317 return Response . redirect ( url . toString ( ) , 302 ) ;
301318 }
302319
303320 // Ensure OpenCode server is running
304321 try {
305- await ensureOpencodeServer ( sandbox , port , options ?. config , options ?. logger ) ;
322+ await ensureOpencodeServer ( sandbox , port , options ?. config ) ;
306323 } catch ( err ) {
307324 const error = err instanceof Error ? err : undefined ;
308- options ?. logger ?. error ( 'Failed to start OpenCode server' , error , {
309- operation : 'opencode.proxy'
325+ getLogger ( ) . error ( 'Failed to start OpenCode server for proxy ' , error , {
326+ port
310327 } ) ;
311328 const message =
312329 err instanceof OpencodeStartupError
0 commit comments