Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cli-approval-public-origin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Pin CLI browser approval links to `EXECUTOR_WEB_BASE_URL` so a TLS reverse proxy no longer returns an unreachable `http://` URL.
12 changes: 12 additions & 0 deletions apps/docs/local/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ executor web # open the web UI at http://127.0.0.1:4788
`executor install` registers Executor so it keeps running across restarts. For a
throwaway foreground runtime instead, run `executor web --foreground`.

## Behind a TLS reverse proxy

The daemon listens over HTTP on loopback. If a reverse proxy terminates TLS in
front of it, set `EXECUTOR_WEB_BASE_URL` to the public HTTPS origin so browser
approval links use that origin:

```bash
EXECUTOR_WEB_BASE_URL=https://executor.example.test executor daemon run --foreground
```

Generated approval URLs take this value, not `X-Forwarded-Proto` or `Host`.

## Connect an agent

Add Executor to any MCP client (Claude Code, Cursor, OpenCode) with `npx add-mcp`.
Expand Down
1 change: 1 addition & 0 deletions apps/local/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
connections: executor.connections,
...appsConfig,
},
webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
createConfigForResource: async (resource) => {
if (resource.kind === "default") {
return {
Expand Down
119 changes: 114 additions & 5 deletions apps/local/src/mcp-browser-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,35 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => {
};
};

const makeMcpFetch = (executor: Executor) => {
const makeMcpFetch = (
executor: Executor,
options: {
readonly webBaseUrl?: string;
readonly extraHeaders?: HeadersInit;
} = {},
) => {
const engine = createExecutionEngine({
executor,
codeExecutor: makeQuickJsExecutor(),
});
const mcp = createMcpRequestHandler({ engine });
const mcp = createMcpRequestHandler(
options.webBaseUrl === undefined
? { engine }
: { defaultConfig: { engine }, webBaseUrl: options.webBaseUrl },
);

const fetchImpl: typeof globalThis.fetch = Object.assign(
(input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
const url = new URL(request.url);
if (url.pathname.startsWith("/mcp")) return mcp.handleRequest(request);
const headers = new Headers(request.headers);
if (options.extraHeaders) {
new Headers(options.extraHeaders).forEach((value, key) => headers.set(key, value));
}
const forwarded = new Request(request, { headers });
const url = new URL(forwarded.url);
if (url.pathname.startsWith("/mcp")) return mcp.handleRequest(forwarded);
if (url.pathname.startsWith("/api/mcp-sessions/")) {
return mcp.handleApprovalRequest(request);
return mcp.handleApprovalRequest(forwarded);
}
return Promise.resolve(new Response("Not found", { status: 404 }));
},
Expand Down Expand Up @@ -192,6 +207,11 @@ describe("local MCP browser approval resume", () => {

expect(first.isError).toBeFalsy();
const firstApproval = readApproval(first.structuredContent);
expect(firstApproval.url.origin).toBe(TEST_BASE_URL);
expect(firstApproval.url.pathname).toBe(
`/resume/${encodeURIComponent(firstApproval.executionId)}`,
);
expect(firstApproval.url.searchParams.get("mcp_session_id")).not.toBeNull();

const second = await approveInBrowserThenResume(fetch, mcpClient, firstApproval);
const secondApproval = readApproval(second.structuredContent);
Expand Down Expand Up @@ -228,6 +248,95 @@ describe("local MCP browser approval resume", () => {
rmSync(tmpDir, { recursive: true, force: true });
}
}, 10_000);

it("uses EXECUTOR_WEB_BASE_URL for approval links when the request is internal HTTP", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-browser-resume-origin-"));
const executor = await makeExecutor(tmpDir);
const { fetch, dispose } = makeMcpFetch(executor, {
webBaseUrl: "https://executor.example.test:8443/prefix?from-base=1",
extraHeaders: {
"x-forwarded-proto": "https",
"x-forwarded-host": "poisoned.example",
},
});
const mcpClient = new Client(
{ name: "browser-resume-origin-test-client", version: "1.0.0" },
{ capabilities: {} },
);
const transport = new StreamableHTTPClientTransport(
new URL("/mcp?elicitation_mode=browser", "http://127.0.0.1:4788"),
{ fetch },
);

await mcpClient.connect(transport);

// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test owns MCP transports, web handler, and executor lifecycle
try {
const paused = await mcpClient.callTool({
name: "execute",
arguments: {
code: `return await tools.api.singleApproval({});`,
},
});

expect(paused.isError).toBeFalsy();
const approval = readApproval(paused.structuredContent);
expect(approval.url.origin).toBe("https://executor.example.test:8443");
expect(approval.url.pathname).toBe(`/resume/${encodeURIComponent(approval.executionId)}`);
expect(approval.url.pathname).not.toContain("/prefix");
expect(approval.url.searchParams.get("from-base")).toBeNull();
expect(approval.url.searchParams.get("mcp_session_id")).not.toBeNull();
expect(approval.url.host).not.toBe("poisoned.example");
expect(approval.url.protocol).not.toBe("http:");
} finally {
await mcpClient.close();
await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => dispose())));
await Effect.runPromise(
Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))),
);
rmSync(tmpDir, { recursive: true, force: true });
}
}, 10_000);

it("falls back to the request origin when EXECUTOR_WEB_BASE_URL uses ephemeral port 0", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-browser-resume-port0-"));
const executor = await makeExecutor(tmpDir);
const { fetch, dispose } = makeMcpFetch(executor, {
webBaseUrl: "http://127.0.0.1:0",
});
const mcpClient = new Client(
{ name: "browser-resume-port0-test-client", version: "1.0.0" },
{ capabilities: {} },
);
const transport = new StreamableHTTPClientTransport(
new URL("/mcp?elicitation_mode=browser", "http://127.0.0.1:4788"),
{ fetch },
);

await mcpClient.connect(transport);

// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test owns MCP transports, web handler, and executor lifecycle
try {
const paused = await mcpClient.callTool({
name: "execute",
arguments: {
code: `return await tools.api.singleApproval({});`,
},
});

expect(paused.isError).toBeFalsy();
const approval = readApproval(paused.structuredContent);
expect(approval.url.origin).toBe("http://127.0.0.1:4788");
expect(approval.url.port).not.toBe("0");
} finally {
await mcpClient.close();
await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => dispose())));
await Effect.runPromise(
Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))),
);
rmSync(tmpDir, { recursive: true, force: true });
}
}, 10_000);
});

const approveInBrowserThenResume = async (
Expand Down
24 changes: 22 additions & 2 deletions apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type ExecutorMcpServerConfig,
} from "@executor-js/host-mcp/tool-server";
import {
approvalUrlForRequest,
buildResumeApprovalUrl,
decodeResumeResponse,
formatResumeAcknowledgement,
readArtifactsEnabled,
Expand Down Expand Up @@ -56,6 +56,14 @@ export interface LocalMcpRequestHandlerConfig {
readonly createConfigForResource?: (
resource: McpResource,
) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
/**
* Pinned public origin for browser-approval URLs. When set (for example
* `EXECUTOR_WEB_BASE_URL` behind a TLS proxy) it is preferred over the
* request URL, whose scheme is the internal HTTP listener. Omit it on
* loopback so the request origin stays the approval link. Port 0 (an
* ephemeral bind placeholder) is treated as unset.
*/
readonly webBaseUrl?: string;
}

// Local serves these error bodies in-process; like the self-host store they are
Expand Down Expand Up @@ -122,6 +130,14 @@ const normalizeHandlerConfig = (
input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });

// `--port 0` (e2e, some CLI boots) installs EXECUTOR_WEB_BASE_URL with port 0
// before the OS assigns a listen port. That origin is not browser-reachable
// (Chrome ERR_UNSAFE_PORT), so approval URLs fall back to the request.
const resumeApprovalOrigin = (configured: string | undefined, requestUrl: string): string => {
if (configured === undefined || configured.length === 0) return requestUrl;
return new URL(configured).port === "0" ? requestUrl : configured;
};

export const createMcpRequestHandler = (
input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
): McpRequestHandler => {
Expand Down Expand Up @@ -235,7 +251,11 @@ export const createMcpRequestHandler = (
? {
mode: "browser" as const,
approvalUrl: (executionId) =>
approvalUrlForRequest(request, executionId, createdSessionId),
buildResumeApprovalUrl({
origin: resumeApprovalOrigin(handlerConfig.webBaseUrl, request.url),
executionId,
sessionId: createdSessionId,
}),
}
: { mode: elicitationMode },
}),
Expand Down
Loading