Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/cloud/src/api/protected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { withExecutionUsageTracking } from "../engine/execution-usage";
const makeBaseEngine = (): ExecutionEngine =>
({
execute: () => Effect.succeed({ result: "ok", logs: [] }),
call: () => Effect.succeed({ result: "ok", logs: [] }),
executeWithPause: () =>
Effect.succeed({
status: "completed",
Expand Down
13 changes: 11 additions & 2 deletions apps/cloud/src/engine/execution-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
// Usage is tracked to Autumn after every execution (execution-usage.ts), but
// nothing ever CHECKED the balance before running, so a free-tier org could run
// unbounded executions past its quota. This gate consults
// `AutumnService.checkExecutionBalance` before `execute` / `executeWithPause`.
// `AutumnService.checkExecutionBalance` before `execute` / `call` /
// `executeWithPause`.
// `resume` is never gated: a paused execution already consumed its quota slot
// when it started, and blocking resume would strand approved work forever.
//
Expand Down Expand Up @@ -68,7 +69,7 @@ export type GateDecision =
| { readonly blocked: true; readonly error: { readonly message: string } };

/**
* Wrap an engine so `decide` runs before `execute` / `executeWithPause`. A
* Wrap an engine so `decide` runs before `execute` / `call` / `executeWithPause`. A
* blocked decision short-circuits to a descriptive `ExecuteResult.error`
* (which `formatExecuteResult` renders as a clean `isError` MCP tool result)
* WITHOUT invoking the inner engine — so a blocked execution is neither run
Expand All @@ -87,6 +88,14 @@ export const withPreExecutionGate = <E extends Cause.YieldableError>(
? Effect.succeed({ result: null, error: decision.error.message })
: engine.execute(code, options),
),
call: (address, args, options) =>
Effect.flatMap(
decide,
(decision): Effect.Effect<EngineExecuteResult, E> =>
decision.blocked
? Effect.succeed({ result: null, error: decision.error.message })
: engine.call(address, args, options),
),
executeWithPause: (code, options) =>
Effect.flatMap(
decide,
Expand Down
22 changes: 22 additions & 0 deletions apps/cloud/src/engine/execution-rate-limit.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class UpstreamDownError extends Data.TaggedError("UpstreamDownError")<{
// the two came back. A blocked decision never reaches the engine at all.
const engineStub: ExecutionEngine = {
execute: () => Effect.succeed({ result: "ran" }),
call: () => Effect.succeed({ result: "ran" }),
executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ran" } }),
resume: () => Effect.succeed(null),
getPausedExecution: () => Effect.succeed(null),
Expand All @@ -49,6 +50,27 @@ const runExecute = (limiter: ReturnType<typeof makeExecutionRateLimiter>) =>
}),
);

describe("execution rate limiter: direct tool calls", () => {
it("counts and blocks a direct call like an execute", async () => {
const limiter = makeExecutionRateLimiter(countingIncrement([1, 11]), {
limit: 10,
isExempt: () => Effect.succeed(false),
});
const engine = limiter.decorate(ORG, engineStub);
const call = () =>
Effect.runPromise(
engine.call(
"tools.github.org.main.getRepo",
{},
{ onElicitation: () => Effect.die("elicitation is not exercised here") },
),
);

expect(await call()).toEqual({ result: "ran" });
expect(await call()).toEqual({ result: null, error: RATE_LIMIT_BLOCKED_MESSAGE });
});
});

describe("execution rate limiter — paid exemption", () => {
it("allows executions under the cap without consulting the exemption", async () => {
let exemptionCalls = 0;
Expand Down
4 changes: 4 additions & 0 deletions apps/cloud/src/engine/execution-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>(
engine
.execute(code, options)
.pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
call: (address, args, options) =>
engine
.call(address, args, options)
.pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
// `options` MUST be forwarded: it carries `autoApprove`, and dropping it
// silently turned every auto-approved execution back into a paused one.
executeWithPause: (code, options) =>
Expand Down
8 changes: 7 additions & 1 deletion apps/host-cloudflare/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,20 @@ import { makeCloudflarePlugins } from "./plugins";
export { makeExecutionStack } from "@executor-js/api/server";
export { EngineDecoratorNoop };

const workerLoader = (): WorkerLoader | undefined => (env as { LOADER?: WorkerLoader }).LOADER;

export const CloudflareCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
CodeExecutorProvider,
() => {
const loader = (env as { LOADER?: WorkerLoader }).LOADER;
const loader = workerLoader();
return loader ? makeDynamicWorkerExecutor({ loader }) : makeQuickJsExecutor();
},
);

/** The code runtime `CloudflareCodeExecutorProvider` picks, for the MCP log line. */
export const cloudflareCodeRuntime = (): "dynamic-worker" | "quickjs" =>
workerLoader() ? "dynamic-worker" : "quickjs";

export const makeCloudflarePluginsProvider = (
config: CloudflareConfig,
): Layer.Layer<PluginsProvider> =>
Expand Down
7 changes: 6 additions & 1 deletion apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import type { ResumeResponse } from "@executor-js/execution";

import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
import { createD1ExecutorDb } from "../db/d1";
import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution";
import {
cloudflareCodeRuntime,
makeCloudflareExecutionStackLayer,
makeExecutionStack,
} from "../execution";

// ---------------------------------------------------------------------------
// Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare
Expand Down Expand Up @@ -162,6 +166,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
const mcpServer = yield* createExecutorMcpServer({
engine,
codeRuntime: cloudflareCodeRuntime(),
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
Expand Down
1 change: 1 addition & 0 deletions apps/local/src/mcp-pre-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const MCP_POST_HEADERS = {
/** No code ever runs here: these requests are answered before any tool call. */
const stubEngine: ExecutionEngine<never> = {
execute: () => Effect.succeed({ result: "unused" }),
call: () => Effect.succeed({ result: "unused" }),
executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "unused" } }),
resume: () => Effect.succeed(null),
getPausedExecution: () => Effect.succeed(null),
Expand Down
31 changes: 31 additions & 0 deletions packages/core/analytics/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const makeRecorder = () => {
};
};

const declineAll = () => Effect.succeed({ action: "decline" as const });

const completedResult = (error?: string): ExecutionResult => ({
status: "completed",
result: { result: error ? null : "ok", ...(error ? { error } : {}) },
Expand All @@ -49,11 +51,13 @@ const pausedResult: ExecutionResult = {
const makeFakeEngine = (
outcomes: Partial<{
execute: Effect.Effect<{ result: unknown; error?: string }, FakeExecutionError>;
call: Effect.Effect<{ result: unknown; error?: string }, FakeExecutionError>;
executeWithPause: Effect.Effect<ExecutionResult, FakeExecutionError>;
resume: Effect.Effect<ExecutionResult | null, FakeExecutionError>;
}>,
): ExecutionEngine<FakeExecutionError> => ({
execute: () => outcomes.execute ?? Effect.succeed({ result: "ok" }),
call: () => outcomes.call ?? Effect.succeed({ result: "ok" }),
executeWithPause: () => outcomes.executeWithPause ?? Effect.succeed(completedResult()),
resume: () => outcomes.resume ?? Effect.succeed(null),
getPausedExecution: () => Effect.succeed(null),
Expand Down Expand Up @@ -145,6 +149,33 @@ describe("withExecutionAnalytics", () => {
}),
);

it.effect("records each direct tool call as one execution", () =>
Effect.gen(function* () {
const recorder = makeRecorder();
const ok = withExecutionAnalytics(makeFakeEngine({}), recorder.analytics, {
plane: "mcp",
toolkit: false,
});
yield* ok.call("tools.github.org.main.getRepo", {}, { onElicitation: declineAll });
const failed = withExecutionAnalytics(
makeFakeEngine({ call: Effect.succeed({ result: null, error: "declined" }) }),
recorder.analytics,
{ plane: "mcp", toolkit: false },
);
yield* failed.call("tools.github.org.main.getRepo", {}, { onElicitation: declineAll });
expect(recorder.events).toEqual([
{
name: "execution_completed",
properties: { ok: true, plane: "mcp", toolkit: false },
},
{
name: "execution_completed",
properties: { ok: false, plane: "mcp", toolkit: false },
},
]);
}),
);

it.effect("resume of an unknown execution records nothing", () =>
Effect.gen(function* () {
const recorder = makeRecorder();
Expand Down
5 changes: 5 additions & 0 deletions packages/core/analytics/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export const withExecutionAnalytics = <E extends Cause.YieldableError>(
Effect.tap((result) => completed(!result.error)),
Effect.tapError(() => completed(false)),
),
call: (address, args, options) =>
engine.call(address, args, options).pipe(
Effect.tap((result) => completed(!result.error)),
Effect.tapError(() => completed(false)),
),
executeWithPause: (code, options) =>
engine.executeWithPause(code, options).pipe(
Effect.tap(recordOutcome),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/api/src/server/execution-stack-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,15 @@ class PlatformEngineUnavailable extends Data.TaggedError("PlatformEngineUnavaila
* actually reach — `getPausedExecution`, via `GET /executions/:id` — answers
* null (a 404), honestly: the platform view owns no executions. The paused
* counters answer the same empty story for any future reader. Everything else
* (execute, resume, the MCP tool description) exists to satisfy the service
* (execute, call, resume, the MCP tool description) exists to satisfy the service
* shape, sits behind the middleware's safe-request gate, and dies if reached.
*/
const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = {
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
execute: () => Effect.die(new PlatformEngineUnavailable({ member: "execute" })),
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
call: () => Effect.die(new PlatformEngineUnavailable({ member: "call" })),
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
executeWithPause: () => Effect.die(new PlatformEngineUnavailable({ member: "executeWithPause" })),
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
resume: () => Effect.die(new PlatformEngineUnavailable({ member: "resume" })),
Expand Down
50 changes: 50 additions & 0 deletions packages/core/execution/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,22 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError>
options: { readonly onElicitation: ElicitationHandler },
) => Effect.Effect<ExecuteResult, E>;

/**
* Call one tool by address in the host, with no sandbox. The result has the
* shape `execute` returns for `return await tools.<path>(args)`: the tool's
* `ToolResult` on success, or a `tool_error` for a declined approval or a
* plugin defect.
*
* Required, not optional, for the same reason as `shutdown`: a decorator
* that dropped it would silently remove billing, gating, or analytics from
* direct calls.
*/
readonly call: (
address: string,
args: unknown,
options: { readonly onElicitation: ElicitationHandler },
) => Effect.Effect<ExecuteResult, E>;

/**
* Execute code, intercepting the first elicitation as a pause point.
* Use this when the host doesn't support inline elicitation.
Expand Down Expand Up @@ -874,6 +890,39 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
return result;
});

const callTool = Effect.fn("mcp.execute")(function* (
address: string,
args: unknown,
options: { readonly onElicitation: ElicitationHandler },
) {
yield* Effect.annotateCurrentSpan({ "mcp.execute.mode": "direct" });
const toolPaths: string[] = [];
const invoker = makeExecutorToolInvoker(executor, {
invokeOptions: { onElicitation: options.onElicitation },
onConnectedToolCall: (path) => toolPaths.push(path),
});
const path = address.startsWith("tools.") ? address.slice("tools.".length) : address;
const result = yield* invoker.invoke({ path, args }).pipe(
Effect.map(
(value): ExecuteResult => ({
result: value,
logs: [],
...(toolPaths.length === 0 ? {} : { toolPaths }),
}),
),
Effect.catchTag("ExecutionToolError", (error) =>
Effect.succeed<ExecuteResult>({
result: null,
error: error.message,
errorKind: "tool_error",
logs: [],
}),
),
);
yield* annotateExecuteOutcome(result);
return result;
});

/**
* End this engine's sandbox fibers, and WAIT for them to finish unwinding.
*
Expand All @@ -898,6 +947,7 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu

return {
execute: runInlineExecution,
call: callTool,
executeWithPause: startPausableExecution,
resume: resumeExecution,
shutdown,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/execution/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export type ExecutionEngine = {
code: string,
options: { readonly onElicitation: ElicitationHandler },
) => Promise<ExecuteResult>;
readonly call: (
address: string,
args: unknown,
options: { readonly onElicitation: ElicitationHandler },
) => Promise<ExecuteResult>;
readonly executeWithPause: (
code: string,
options?: { readonly autoApprove?: boolean },
Expand Down Expand Up @@ -154,6 +159,14 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>(
Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
}),
),
call: (address, args, options) =>
Effect.runPromise(
engine.call(address, args, {
onElicitation: (ctx) =>
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: host-provided Promise elicitation callback is outside the Effect error model
Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
}),
),
executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)),
resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),
Expand Down
Loading
Loading