From 769b4383596b38e70bb0048ce46786385951a9de Mon Sep 17 00:00:00 2001 From: utpal singh Date: Tue, 8 Sep 2026 23:21:12 +0530 Subject: [PATCH 1/2] fix: use public origin for approval URLs CLI browser approval links ignored EXECUTOR_WEB_BASE_URL and inherited the internal HTTP listener scheme, so TLS-proxied deployments got unreachable http:// URLs. --- .changeset/cli-approval-public-origin.md | 5 ++ apps/docs/local/cli.mdx | 12 ++++ apps/local/src/main.ts | 1 + apps/local/src/mcp-browser-resume.test.ts | 79 +++++++++++++++++++++-- apps/local/src/mcp.ts | 15 ++++- 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 .changeset/cli-approval-public-origin.md diff --git a/.changeset/cli-approval-public-origin.md b/.changeset/cli-approval-public-origin.md new file mode 100644 index 0000000000..ebe473e9c0 --- /dev/null +++ b/.changeset/cli-approval-public-origin.md @@ -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. diff --git a/apps/docs/local/cli.mdx b/apps/docs/local/cli.mdx index f31a8b7855..68a380d826 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -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`. diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 2ef674c572..12c314574c 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -125,6 +125,7 @@ export const createServerHandlers = async (token: string): Promise { if (resource.kind === "default") { return { diff --git a/apps/local/src/mcp-browser-resume.test.ts b/apps/local/src/mcp-browser-resume.test.ts index 32d5c03c9c..d3b2b58cc8 100644 --- a/apps/local/src/mcp-browser-resume.test.ts +++ b/apps/local/src/mcp-browser-resume.test.ts @@ -104,20 +104,35 @@ const makeExecutor = async (tmpDir: string): Promise => { }; }; -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 })); }, @@ -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); @@ -228,6 +248,55 @@ 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); }); const approveInBrowserThenResume = async ( diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index caa5548d4a..1e8bf98e47 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -15,7 +15,7 @@ import { type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server"; import { - approvalUrlForRequest, + buildResumeApprovalUrl, decodeResumeResponse, formatResumeAcknowledgement, readArtifactsEnabled, @@ -56,6 +56,13 @@ export interface LocalMcpRequestHandlerConfig { readonly createConfigForResource?: ( resource: McpResource, ) => Promise | 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. + */ + readonly webBaseUrl?: string; } // Local serves these error bodies in-process; like the self-host store they are @@ -235,7 +242,11 @@ export const createMcpRequestHandler = ( ? { mode: "browser" as const, approvalUrl: (executionId) => - approvalUrlForRequest(request, executionId, createdSessionId), + buildResumeApprovalUrl({ + origin: handlerConfig.webBaseUrl ?? request.url, + executionId, + sessionId: createdSessionId, + }), } : { mode: elicitationMode }, }), From 1fcae9587421533ea7b82c3e82a151b9bf6731ef Mon Sep 17 00:00:00 2001 From: utpal singh Date: Tue, 8 Sep 2026 23:29:58 +0530 Subject: [PATCH 2/2] fix: skip port-0 web base URL for approval links CLI --port 0 installs EXECUTOR_WEB_BASE_URL as http://127.0.0.1:0 before the OS assigns a listen port. Chrome rejects that origin as ERR_UNSAFE_PORT, so approval URLs fall back to the request origin in that case. --- apps/local/src/mcp-browser-resume.test.ts | 40 +++++++++++++++++++++++ apps/local/src/mcp.ts | 13 ++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/local/src/mcp-browser-resume.test.ts b/apps/local/src/mcp-browser-resume.test.ts index d3b2b58cc8..e9b51acd80 100644 --- a/apps/local/src/mcp-browser-resume.test.ts +++ b/apps/local/src/mcp-browser-resume.test.ts @@ -297,6 +297,46 @@ describe("local MCP browser approval resume", () => { 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 ( diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 1e8bf98e47..0ea30d6d84 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -60,7 +60,8 @@ export interface LocalMcpRequestHandlerConfig { * 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. + * loopback so the request origin stays the approval link. Port 0 (an + * ephemeral bind placeholder) is treated as unset. */ readonly webBaseUrl?: string; } @@ -129,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 => { @@ -243,7 +252,7 @@ export const createMcpRequestHandler = ( mode: "browser" as const, approvalUrl: (executionId) => buildResumeApprovalUrl({ - origin: handlerConfig.webBaseUrl ?? request.url, + origin: resumeApprovalOrigin(handlerConfig.webBaseUrl, request.url), executionId, sessionId: createdSessionId, }),