Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/tools/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface CodeExecutorEntrypoint {
evaluate(): Promise<{ result: unknown; err?: string; stack?: string }>
}

export const EXECUTE_TIMEOUT_MS = 60_000

type GlobalOutboundProps = { apiToken: string; fetchWithRetryCaller: string }

/**
Expand Down Expand Up @@ -59,10 +61,11 @@ export class GlobalOutbound extends WorkerEntrypoint<Env, GlobalOutboundProps> {
* a warm isolate), and the API token is injected via the GlobalOutbound props
* so it never enters the user code isolate.
*/
async function runExecute(
export async function runExecute(
code: string,
accountId: string | undefined,
apiToken: string
apiToken: string,
timeoutMs = EXECUTE_TIMEOUT_MS
): Promise<unknown> {
const apiBase = env.CLOUDFLARE_API_BASE
const workerId = `cloudflare-api-${crypto.randomUUID()}`
Expand All @@ -89,6 +92,8 @@ async function runExecute(
import { WorkerEntrypoint } from "cloudflare:workers";

const apiBase = ${JSON.stringify(apiBase)};
const executeTimeoutMs = ${JSON.stringify(timeoutMs)};
const executeTimeoutError = ${JSON.stringify(`Execution timed out after ${timeoutMs / 1000} seconds`)};
${accountIdPrelude}

export default class CodeExecutor extends WorkerEntrypoint {
Expand Down Expand Up @@ -181,11 +186,20 @@ export default class CodeExecutor extends WorkerEntrypoint {
}
};

let timeoutId;
try {
const result = await (${code})();
const execution = Promise.resolve().then(() => (${code})());
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(executeTimeoutError)), executeTimeoutMs);
});
const result = await Promise.race([execution, timeout]);
return { result, err: undefined };
} catch (err) {
return { result: undefined, err: err.message, stack: err.stack };
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
}
Expand Down
32 changes: 27 additions & 5 deletions tests/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { clearKv } from './helpers/kv'
import { clearSpec, seedSpec } from './helpers/spec'
import { callTool, toolText } from './helpers/mcp'
import { server } from './setup/msw'
import { EXECUTE_TIMEOUT_MS, runExecute as runExecuteCode } from '../src/tools/execute'

/**
* Behaviour tests for the code executors, run through the REAL worker: a
Expand All @@ -25,7 +26,11 @@ afterEach(async () => {
})

/** Run an `execute` tool call whose code hits `path`, with MSW returning `body`. */
async function runExecute(path: string, body: unknown, init?: ResponseInit): Promise<string> {
async function runExecuteRequest(
path: string,
body: unknown,
init?: ResponseInit
): Promise<string> {
mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] })
server.use(
http.get(`${API_BASE}${path}`, () =>
Expand All @@ -40,9 +45,26 @@ async function runExecute(path: string, body: unknown, init?: ResponseInit): Pro
return toolText(result)
}

describe('execute: dynamic worker timeout', () => {
it('defaults to 60 seconds', () => {
expect(EXECUTE_TIMEOUT_MS).toBe(60_000)
})

it('rejects when sandboxed code exceeds the configured timeout', async () => {
await expect(
runExecuteCode(
'async () => new Promise((resolve) => setTimeout(resolve, 100))',
ACCOUNT_ID,
API_TOKEN,
10
)
).rejects.toThrow('Execution timed out after 0.01 seconds')
})
})

describe('execute: REST responses', () => {
it('returns the success envelope with the response status', async () => {
const text = await runExecute(
const text = await runExecuteRequest(
`/accounts/${ACCOUNT_ID}/tokens/verify`,
cfSuccess({ status: 'active' })
)
Expand All @@ -51,7 +73,7 @@ describe('execute: REST responses', () => {
})

it('surfaces a clean "Cloudflare API error" for a failure envelope with errors', async () => {
const text = await runExecute(
const text = await runExecuteRequest(
`/accounts/${ACCOUNT_ID}/tokens/verify`,
{
success: false,
Expand All @@ -69,7 +91,7 @@ describe('execute: REST responses', () => {
// Regression: the REST branch must not assume data.errors is an array.
// A {success:false} body with a missing errors array (e.g. a gateway/proxy
// envelope) previously threw "Cannot read properties of undefined (map)".
const text = await runExecute(
const text = await runExecuteRequest(
`/accounts/${ACCOUNT_ID}/tokens/verify`,
{ success: false, result: null },
{ status: 502 }
Expand All @@ -80,7 +102,7 @@ describe('execute: REST responses', () => {
})

it('returns non-JSON responses as raw text', async () => {
const text = await runExecute(`/accounts/${ACCOUNT_ID}/something`, 'raw-value', {
const text = await runExecuteRequest(`/accounts/${ACCOUNT_ID}/something`, 'raw-value', {
headers: { 'Content-Type': 'text/plain' }
})
expect(text).toContain('raw-value')
Expand Down