Skip to content

Commit b51d45d

Browse files
committed
Improve OpenCode type safety and add internal logging
- Use @opencode-ai/sdk Config type instead of Record<string, unknown> - Fix API key extraction path (options.apiKey) - Add OpencodeStartupContext for structured error context - Remove Logger from public options (internal concern) - Add internal logging for debugging without exposing in API - Fix Sandbox<any> to Sandbox<unknown>
1 parent 3c8d0e2 commit b51d45d

File tree

7 files changed

+111
-82
lines changed

7 files changed

+111
-82
lines changed

examples/opencode/src/index.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@
77
*/
88
import { getSandbox } from '@cloudflare/sandbox';
99
import { createOpencode, proxyToOpencode } from '@cloudflare/sandbox/opencode';
10-
import type { OpencodeClient } from '@opencode-ai/sdk';
10+
import type { Config, OpencodeClient } from '@opencode-ai/sdk';
11+
12+
const getConfig = (env: Env): Config => ({
13+
provider: {
14+
anthropic: {
15+
options: {
16+
apiKey: env.ANTHROPIC_API_KEY
17+
}
18+
}
19+
}
20+
});
1121

1222
export default {
1323
async fetch(request: Request, env: Env): Promise<Response> {
@@ -20,15 +30,7 @@ export default {
2030
}
2131

2232
// Everything else: Web UI proxy
23-
return proxyToOpencode(request, sandbox, {
24-
config: {
25-
provider: {
26-
anthropic: {
27-
apiKey: env.ANTHROPIC_API_KEY
28-
}
29-
}
30-
}
31-
});
33+
return proxyToOpencode(request, sandbox, { config: getConfig(env) });
3234
}
3335
};
3436

@@ -47,15 +49,10 @@ async function handleSdkTest(
4749

4850
// Get typed SDK client
4951
const { client } = await createOpencode<OpencodeClient>(sandbox, {
50-
config: {
51-
provider: {
52-
anthropic: {
53-
apiKey: env.ANTHROPIC_API_KEY
54-
}
55-
}
56-
}
52+
config: getConfig(env)
5753
});
5854

55+
console.log('Client created:', client);
5956
// Create a session
6057
const session = await client.session.create({
6158
body: { title: 'Test Session' },
@@ -66,6 +63,7 @@ async function handleSdkTest(
6663
throw new Error(`Failed to create session: ${JSON.stringify(session)}`);
6764
}
6865

66+
console.log('Session created:', session.data);
6967
// Send a prompt using the SDK
7068
const promptResult = await client.session.prompt({
7169
path: { id: session.data.id },
@@ -84,6 +82,7 @@ async function handleSdkTest(
8482
}
8583
});
8684

85+
console.log('Prompt result:', promptResult.data);
8786
// Extract text response from result
8887
const parts = promptResult.data?.parts ?? [];
8988
const textPart = parts.find((p: { type: string }) => p.type === 'text') as

packages/sandbox/src/opencode/fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type { Sandbox } from '../sandbox';
2525
* ```
2626
*/
2727
export function createSandboxFetch(
28-
sandbox: Sandbox<any>,
28+
sandbox: Sandbox<unknown>,
2929
port = 4096
3030
): (request: Request) => Promise<Response> {
3131
return (request: Request): Promise<Response> => {

packages/sandbox/src/opencode/opencode.ts

Lines changed: 65 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
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';
23
import type { Sandbox } from '../sandbox';
34
import { createSandboxFetch } from './fetch';
45
import type {
@@ -9,7 +10,14 @@ import type {
910
} from './types';
1011
import { 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+
1218
const 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(
6168
async 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(
128138
async 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

packages/sandbox/src/opencode/types.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
1-
// packages/sandbox/src/opencode/types.ts
2-
import type { Logger, Process } from '@repo/shared';
3-
import { ErrorCode } from '@repo/shared/errors';
1+
import type { Config } from '@opencode-ai/sdk';
2+
import type { Process } from '@repo/shared';
3+
import { ErrorCode, type OpencodeStartupContext } from '@repo/shared/errors';
44

55
/**
66
* Configuration options for starting OpenCode server
7-
* Uses OpencodeConfig from @opencode-ai/sdk for provider configuration
87
*/
98
export interface OpencodeOptions {
109
/** Port for OpenCode server (default: 4096) */
1110
port?: number;
12-
/** OpenCode configuration - passed via OPENCODE_CONFIG_CONTENT env var */
13-
config?: Record<string, unknown>;
14-
/** Logger for debug output */
15-
logger?: Logger;
11+
/** OpenCode configuration */
12+
config?: Config;
1613
}
1714

1815
/**
@@ -46,20 +43,24 @@ export interface OpencodeResult<TClient = unknown> {
4643
export interface ProxyToOpencodeOptions {
4744
/** Port for OpenCode server (default: 4096) */
4845
port?: number;
49-
/** OpenCode configuration - passed via OPENCODE_CONFIG_CONTENT env var */
50-
config?: Record<string, unknown>;
51-
/** Logger for debug output */
52-
logger?: Logger;
46+
/** OpenCode configuration */
47+
config?: Config;
5348
}
5449

5550
/**
5651
* Error thrown when OpenCode server fails to start
5752
*/
5853
export class OpencodeStartupError extends Error {
5954
public readonly code = ErrorCode.OPENCODE_STARTUP_FAILED;
55+
public readonly context: OpencodeStartupContext;
6056

61-
constructor(message: string, options?: ErrorOptions) {
57+
constructor(
58+
message: string,
59+
context: OpencodeStartupContext,
60+
options?: ErrorOptions
61+
) {
6262
super(message, options);
6363
this.name = 'OpencodeStartupError';
64+
this.context = context;
6465
}
6566
}

packages/sandbox/tests/opencode/opencode.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ describe('createOpencode', () => {
8989
});
9090

9191
it('should pass config via OPENCODE_CONFIG_CONTENT env var', async () => {
92-
const config = { provider: { anthropic: { apiKey: 'test-key' } } };
92+
const config = {
93+
provider: { anthropic: { options: { apiKey: 'test-key' } } }
94+
};
9395
await createOpencode(mockSandbox as unknown as Sandbox, { config });
9496

9597
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
@@ -105,8 +107,8 @@ describe('createOpencode', () => {
105107
it('should extract API keys from config to env vars', async () => {
106108
const config = {
107109
provider: {
108-
anthropic: { apiKey: 'anthropic-key' },
109-
openai: { apiKey: 'openai-key' }
110+
anthropic: { options: { apiKey: 'anthropic-key' } },
111+
openai: { options: { apiKey: 'openai-key' } }
110112
}
111113
};
112114
await createOpencode(mockSandbox as unknown as Sandbox, { config });

0 commit comments

Comments
 (0)