diff --git a/apps/cli/src/daemon.test.ts b/apps/cli/src/daemon.test.ts index 4f4e3963e..6005b6a2b 100644 --- a/apps/cli/src/daemon.test.ts +++ b/apps/cli/src/daemon.test.ts @@ -19,7 +19,7 @@ describe("canAutoStartLocalDaemonForHost", () => { }); describe("isExecutorServerReachable", () => { - it.effect("checks the v1.5 API surface instead of the removed scope endpoint", () => + it.effect("probes the unauthenticated /api/health endpoint without forwarding a credential", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( Effect.tryPromise( @@ -27,9 +27,10 @@ describe("isExecutorServerReachable", () => { new Promise<{ server: Server; port: number }>((resolve, reject) => { const server = createServer((request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); - if (url.pathname === "/api/integrations") { - response.writeHead(200, { "content-type": "application/json" }); - response.end("[]"); + // The probe must NOT send Authorization, and must hit /api/health. + if (url.pathname === "/api/health" && !request.headers.authorization) { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); return; } response.writeHead(404); @@ -53,12 +54,6 @@ describe("isExecutorServerReachable", () => { ), ); - const legacyScopeStatus = yield* Effect.tryPromise(() => - fetch(`http://127.0.0.1:${server.port}/api/scope`), - ).pipe(Effect.map((response) => response.status)); - - expect(legacyScopeStatus).toBe(404); - const reachable = yield* isExecutorServerReachable({ baseUrl: `http://127.0.0.1:${server.port}`, }); diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 8be7db064..087b7ab99 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -19,7 +19,6 @@ export interface DaemonSpawnSpec { export interface ExecutorServerReachabilityInput { readonly baseUrl: string; - readonly authorization?: string; } type ProbeServer = ReturnType & { @@ -69,11 +68,10 @@ export const isExecutorServerReachable = ( input: ExecutorServerReachabilityInput, ): Effect.Effect => Effect.tryPromise(async () => { - const url = new URL("/api/integrations", input.baseUrl); - const response = await fetch(url, { - ...(input.authorization ? { headers: { authorization: input.authorization } } : {}), - signal: AbortSignal.timeout(2000), - }); + // The unauthenticated liveness probe — never forwards a credential, so a + // misconfigured base URL can't leak the bearer token to a third-party host. + const url = new URL("/api/health", input.baseUrl); + const response = await fetch(url, { signal: AbortSignal.timeout(2000) }); await response.body?.cancel(); return response.ok; }).pipe(Effect.catchCause(() => Effect.succeed(false))); diff --git a/apps/cli/src/local-server-manifest.ts b/apps/cli/src/local-server-manifest.ts index 7ec8abd8a..9101dc58b 100644 --- a/apps/cli/src/local-server-manifest.ts +++ b/apps/cli/src/local-server-manifest.ts @@ -49,10 +49,15 @@ export const writeLocalServerManifest = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(serverControlDir(path), { recursive: true }); - yield* fs.writeFileString( - localServerManifestPath(path), - serializeExecutorLocalServerManifest(manifest), - ); + const manifestPath = localServerManifestPath(path); + // The manifest embeds the bearer token; create it owner-only so there's no + // window where it exists world-readable (mode applies only on create). The + // chmod after covers overwriting a pre-existing world-readable file, where + // the create mode is ignored. + yield* fs.writeFileString(manifestPath, serializeExecutorLocalServerManifest(manifest), { + mode: 0o600, + }); + yield* fs.chmod(manifestPath, 0o600).pipe(Effect.ignore); }); export const removeLocalServerManifestIfOwnedBy = (input: { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index e64bfb2d7..3152370da 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -2,6 +2,7 @@ // before any import (e.g. `@executor-js/local` → libSQL) eagerly loads them. import "./native-bindings"; +import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -47,7 +48,6 @@ import * as Cause from "effect/Cause"; import { ExecutorApi } from "@executor-js/api"; import { - DEFAULT_EXECUTOR_SERVER_USERNAME, getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, type ExecutorLocalServerKind, @@ -55,7 +55,13 @@ import { type ExecutorServerConnection, type ExecutorServerConnectionInput, } from "@executor-js/sdk/shared"; -import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local"; +import { + startServer, + runMcpStdioServer, + getExecutor, + rotateLocalAuthToken, + localAuthTokenPath, +} from "@executor-js/local"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { fetchIntegrations } from "./integrations"; import { @@ -158,8 +164,8 @@ const waitForShutdownSignal = () => // Background server management // --------------------------------------------------------------------------- -const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect => - isExecutorServerReachable({ baseUrl, authorization }); +const isServerReachable = (baseUrl: string): Effect.Effect => + isExecutorServerReachable({ baseUrl }); const readActiveLocalServerManifest = (): Effect.Effect< ExecutorLocalServerManifest | null, @@ -175,8 +181,7 @@ const readActiveLocalServerManifest = (): Effect.Effect< return null; } - const authorization = getExecutorServerAuthorizationHeader(manifest.connection) ?? undefined; - if (yield* isServerReachable(manifest.connection.origin, authorization)) { + if (yield* isServerReachable(manifest.connection.origin)) { return manifest; } @@ -238,21 +243,6 @@ const parseExecutorServerConnection = (baseUrl: string) => const daemonBaseUrl = (hostname: string, port: number): string => `http://${canonicalDaemonHost(hostname)}:${port}`; -const serverAuthFromInputs = (input: { - readonly authToken: string | undefined; - readonly authPassword: string | undefined; -}): ExecutorServerConnection["auth"] | undefined => { - if (input.authPassword) { - return { - kind: "basic", - username: DEFAULT_EXECUTOR_SERVER_USERNAME, - password: input.authPassword, - }; - } - if (input.authToken) return { kind: "bearer", token: input.authToken }; - return undefined; -}; - const makeLocalServerManifest = (input: { readonly kind: ExecutorLocalServerKind; readonly connection: ExecutorServerConnection; @@ -561,8 +551,7 @@ const resolveExecutorServerConnection = ( if (decision.kind === "use-active") return requested; if (!canAutoStartCliServerConnection(requested)) { - const authorization = getExecutorServerAuthorizationHeader(requested) ?? undefined; - if (yield* isServerReachable(requested.origin, authorization)) { + if (yield* isServerReachable(requested.origin)) { return requested; } return yield* Effect.fail( @@ -570,14 +559,24 @@ const resolveExecutorServerConnection = ( [ `Executor server is not reachable at ${requested.origin}.`, "For hosted Executor, set EXECUTOR_API_KEY to a bearer API key.", - "For password-protected local or desktop servers, set EXECUTOR_AUTH_PASSWORD.", - "For unauthenticated local Executor, use an http://localhost or http://127.0.0.1 server URL.", + "For local or desktop servers, set EXECUTOR_AUTH_TOKEN to the server's bearer token.", ].join("\n"), ), ); } const daemonUrl = yield* ensureDaemon(requested.origin); + // The daemon we just ensured published a manifest carrying its bearer token + // (minted into auth.json). Prefer that authed connection — otherwise the + // next API call hits the now-gated server with no credential and 401s. + const started = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + const daemonOrigin = normalizeExecutorServerConnection({ origin: daemonUrl }).origin; + const startedOrigin = started + ? normalizeExecutorServerConnection({ origin: started.connection.origin }).origin + : null; + if (started && startedOrigin === daemonOrigin) { + return started.connection; + } return normalizeExecutorServerConnection({ ...requested, origin: daemonUrl, @@ -799,7 +798,6 @@ const runForegroundSession = (input: { hostname: string; allowedHosts: ReadonlyArray; authToken: string | undefined; - authPassword: string | undefined; }) => Effect.gen(function* () { const displayHost = @@ -821,7 +819,6 @@ const runForegroundSession = (input: { hostname: input.hostname, allowedHosts: input.allowedHosts, authToken: input.authToken, - authPassword: input.authPassword, embeddedWebUI, }), ); @@ -832,7 +829,7 @@ const runForegroundSession = (input: { kind: "http", origin: baseUrl, displayName: "CLI web", - auth: serverAuthFromInputs(input), + auth: { kind: "bearer", token: server.authToken }, }), }); } finally { @@ -845,6 +842,7 @@ const runForegroundSession = (input: { try { console.log(`Executor is ready.`); + console.log(`Open: ${baseUrl}/?_token=${server.authToken}`); console.log(`Web: ${baseUrl}`); console.log(`MCP: ${baseUrl}/mcp`); console.log(`OpenAPI: ${baseUrl}/api/docs`); @@ -853,12 +851,7 @@ const runForegroundSession = (input: { `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`, ); if (input.allowedHosts.length > 0) { - console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`); - } - if (input.authPassword) { - console.log(" Basic authentication is enabled."); - } else if (input.authToken) { - console.log(" Token authentication is enabled."); + console.log(` Extra CORS origins: ${input.allowedHosts.join(", ")}`); } } console.log(`\nPress Ctrl+C to stop.`); @@ -878,7 +871,6 @@ const runDaemonSession = (input: { hostname: string; allowedHosts: ReadonlyArray; authToken: string | undefined; - authPassword: string | undefined; }) => Effect.gen(function* () { const daemonHost = canonicalDaemonHost(input.hostname); @@ -920,7 +912,6 @@ const runDaemonSession = (input: { hostname: input.hostname, allowedHosts: input.allowedHosts, authToken: input.authToken, - authPassword: input.authPassword, embeddedWebUI, }), ); @@ -934,7 +925,7 @@ const runDaemonSession = (input: { kind: "http", origin: daemonUrl, displayName: "CLI daemon", - auth: serverAuthFromInputs(input), + auth: { kind: "bearer", token: server.authToken }, }), }); } finally { @@ -962,11 +953,6 @@ const runDaemonSession = (input: { }); console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`); - if (input.authPassword) { - console.log("Basic authentication is enabled."); - } else if (input.authToken) { - console.log("Token authentication is enabled."); - } yield* waitForShutdownSignal(); } finally { @@ -1092,6 +1078,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode kind: "http", origin: web.baseUrl, displayName: "CLI MCP", + auth: { kind: "bearer", token: web.server.authToken }, }), }); } finally { @@ -1915,12 +1902,31 @@ const serverRemoveCommand = Command.make( }), ).pipe(Command.withDescription("Remove an Executor server profile")); +const serverRotateTokenCommand = Command.make("rotate-token", {}, () => + Effect.gen(function* () { + const token = rotateLocalAuthToken(); + console.log("Rotated the local server bearer token."); + console.log(`Stored in ${localAuthTokenPath()}`); + + const manifest = yield* readLocalServerManifest(); + if (manifest && isPidAlive(manifest.pid)) { + console.log( + `\n⚠ A local server is running at ${manifest.connection.origin} (pid ${manifest.pid}).`, + ); + console.log(" Restart it to apply the new token."); + } + console.log(`\nNew token: ${token}`); + console.log("Re-run your MCP client connect command with the new token."); + }), +).pipe(Command.withDescription("Rotate the local server's bearer token (auth.json)")); + const serverCommand = Command.make("server").pipe( Command.withSubcommands([ serverAddCommand, serverListCommand, serverUseCommand, serverRemoveCommand, + serverRotateTokenCommand, ] as const), Command.withDescription("Manage named Executor server profiles"), ); @@ -1936,18 +1942,19 @@ const webCommand = Command.make( .pipe(Options.atLeast(0)) .pipe( Options.withDescription( - "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.", + "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.", ), ), authToken: Options.string("auth-token") .pipe(Options.optional) - .pipe(Options.withDescription("Bearer token required for requests.")), - authPassword: Options.string("auth-password") - .pipe(Options.optional) - .pipe(Options.withDescription("Basic auth password required for requests.")), + .pipe( + Options.withDescription( + "Override the bearer token. Defaults to the stable token in auth.json.", + ), + ), scope, }, - ({ port, scope, hostname, allowedHost, authToken, authPassword }) => + ({ port, scope, hostname, allowedHost, authToken }) => Effect.gen(function* () { applyScope(scope); yield* runForegroundSession({ @@ -1955,7 +1962,6 @@ const webCommand = Command.make( hostname, allowedHosts: allowedHost, authToken: Option.getOrUndefined(authToken), - authPassword: Option.getOrUndefined(authPassword), }); }), ).pipe(Command.withDescription("Start a foreground web session")); @@ -1971,15 +1977,16 @@ const daemonRunCommand = Command.make( .pipe(Options.atLeast(0)) .pipe( Options.withDescription( - "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.", + "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.", ), ), authToken: Options.string("auth-token") .pipe(Options.optional) - .pipe(Options.withDescription("Bearer token required for requests.")), - authPassword: Options.string("auth-password") - .pipe(Options.optional) - .pipe(Options.withDescription("Basic auth password required for requests.")), + .pipe( + Options.withDescription( + "Override the bearer token. Defaults to the stable token in auth.json.", + ), + ), foreground: Options.boolean("foreground") .pipe(Options.withDefault(false)) .pipe( @@ -1989,7 +1996,7 @@ const daemonRunCommand = Command.make( ), scope, }, - ({ port, scope, hostname, allowedHost, authToken, authPassword, foreground }) => + ({ port, scope, hostname, allowedHost, authToken, foreground }) => Effect.gen(function* () { applyScope(scope); if (foreground) { @@ -1998,7 +2005,6 @@ const daemonRunCommand = Command.make( hostname, allowedHosts: allowedHost, authToken: Option.getOrUndefined(authToken), - authPassword: Option.getOrUndefined(authPassword), }); } else { yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost }); @@ -2114,6 +2120,48 @@ const mcpCommand = Command.make( // Root command // --------------------------------------------------------------------------- +/** + * Open a URL in the user's default browser. Best-effort: if the platform opener + * isn't found (e.g. headless Linux without xdg-open) we swallow the error — the + * URL is always printed first, so the user can copy it manually. + */ +const openInBrowser = (url: string): Effect.Effect => + Effect.sync(() => { + const [cmd, args]: readonly [string, ReadonlyArray] = + process.platform === "darwin" + ? ["open", [url]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + // best-effort: ignore failures (e.g. no opener on headless Linux). The URL + // was printed above for manual open; execFile's callback absorbs the error, + // so there's no unhandled 'error' event to crash the CLI. + execFile(cmd, [...args], () => {}); + }); + +/** + * `executor open` — the friendly way back in. Reads the running local server's + * manifest and opens the browser straight to its `?_token=` URL, so the user + * never has to copy a bearer token out of a terminal or auth.json by hand. + */ +const openCommand = Command.make("open", {}, () => + Effect.gen(function* () { + const manifest = yield* readLocalServerManifest(); + if (!manifest || !isPidAlive(manifest.pid)) { + console.log("No local Executor server is running."); + console.log(`Start one with: ${cliPrefix} web`); + return; + } + const { origin, auth } = manifest.connection; + const token = auth?.kind === "bearer" ? auth.token : undefined; + const url = token ? `${origin}/?_token=${token}` : origin; + console.log(`Opening ${url}`); + yield* openInBrowser(url); + }), +).pipe( + Command.withDescription("Open the running Executor web app in your browser, already signed in"), +); + const root = Command.make("executor").pipe( Command.withSubcommands([ callCommand, @@ -2123,6 +2171,7 @@ const root = Command.make("executor").pipe( webCommand, daemonCommand, mcpCommand, + openCommand, ] as const), Command.withDescription("Executor local CLI"), ); diff --git a/apps/cli/src/server-connection.test.ts b/apps/cli/src/server-connection.test.ts index ff6033d02..d0f41b6fc 100644 --- a/apps/cli/src/server-connection.test.ts +++ b/apps/cli/src/server-connection.test.ts @@ -43,16 +43,13 @@ describe("CLI server connection", () => { }); }); - it("supports desktop-style basic auth for local server connections", () => { + it("reads a local server bearer token from EXECUTOR_AUTH_TOKEN", () => { const connection = parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }); - expect(connection.auth).toEqual({ - kind: "basic", - username: "executor", - password: "desktop-password", - }); + expect(connection.auth).toEqual({ kind: "bearer", token: "desktop-token" }); + // A connection carrying explicit auth is not an auto-startable local daemon. expect(canAutoStartCliServerConnection(connection)).toBe(false); }); @@ -65,23 +62,12 @@ describe("CLI server connection", () => { expect(canAutoStartCliServerConnection(connection)).toBe(false); }); - it("prefers bearer auth for hosted connections and basic auth for local connections", () => { - const hosted = parseCliExecutorServerConnection("https://executor.example", { - EXECUTOR_API_KEY: "key_123", - EXECUTOR_AUTH_PASSWORD: "desktop-password", - }); - expect(hosted.auth).toEqual({ kind: "bearer", token: "key_123" }); - - const local = parseCliExecutorServerConnection("http://127.0.0.1:4789", { + it("prefers EXECUTOR_API_KEY over EXECUTOR_AUTH_TOKEN", () => { + const connection = parseCliExecutorServerConnection("https://executor.example", { EXECUTOR_API_KEY: "key_123", - EXECUTOR_AUTH_PASSWORD: "desktop-password", - EXECUTOR_AUTH_USERNAME: "rhys", - }); - expect(local.auth).toEqual({ - kind: "basic", - username: "rhys", - password: "desktop-password", + EXECUTOR_AUTH_TOKEN: "token_456", }); + expect(connection.auth).toEqual({ kind: "bearer", token: "key_123" }); }); it("attaches implicit local requests to the active local owner", () => { @@ -94,7 +80,7 @@ describe("CLI server connection", () => { dataDir: "/tmp/executor", scopeDir: "/tmp/executor", connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }), owner: { client: "desktop" as const, @@ -114,9 +100,8 @@ describe("CLI server connection", () => { connection: { origin: "http://127.0.0.1:4789", auth: { - kind: "basic", - username: "executor", - password: "desktop-password", + kind: "bearer", + token: "desktop-token", }, }, }); @@ -132,7 +117,7 @@ describe("CLI server connection", () => { dataDir: "/tmp/executor", scopeDir: "/tmp/executor", connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }), owner: { client: "desktop" as const, diff --git a/apps/cli/src/server-connection.ts b/apps/cli/src/server-connection.ts index 3845e2ea2..ce7fb2a1d 100644 --- a/apps/cli/src/server-connection.ts +++ b/apps/cli/src/server-connection.ts @@ -1,5 +1,4 @@ import { - DEFAULT_EXECUTOR_SERVER_USERNAME, normalizeExecutorServerConnection, type ExecutorLocalServerManifest, type ExecutorServerAuth, @@ -7,43 +6,15 @@ import { } from "@executor-js/sdk/shared"; import { canAutoStartLocalDaemonForHost } from "./daemon"; -const readCliBasicServerAuth = ( - env: Record = process.env, -): ExecutorServerAuth | undefined => { - const password = env.EXECUTOR_AUTH_PASSWORD; - if (!password) return undefined; - return { - kind: "basic", - username: env.EXECUTOR_AUTH_USERNAME ?? DEFAULT_EXECUTOR_SERVER_USERNAME, - password, - }; -}; - -const readCliBearerServerAuth = ( - env: Record = process.env, -): ExecutorServerAuth | undefined => { - const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN; - if (!token) return undefined; - return { kind: "bearer", token }; -}; - +// Bearer is the only credential the CLI derives from the environment: a hosted +// API key (`EXECUTOR_API_KEY`) or a local/desktop server's bearer token +// (`EXECUTOR_AUTH_TOKEN`). Local servers publish their token in the manifest, so +// the env override is mainly for pointing the CLI at a remote instance. export const readCliServerAuth = ( env: Record = process.env, -): ExecutorServerAuth | undefined => readCliBearerServerAuth(env) ?? readCliBasicServerAuth(env); - -const readCliServerAuthForConnection = ( - connection: ExecutorServerConnection, - env: Record = process.env, ): ExecutorServerAuth | undefined => { - const bearer = readCliBearerServerAuth(env); - const basic = readCliBasicServerAuth(env); - const protocol = new URL(connection.origin).protocol; - - if (protocol === "https:") { - return bearer ?? basic; - } - - return basic ?? bearer; + const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN; + return token ? { kind: "bearer", token } : undefined; }; export const parseCliExecutorServerConnection = ( @@ -55,7 +26,7 @@ export const parseCliExecutorServerConnection = ( }); return normalizeExecutorServerConnection({ ...connection, - auth: readCliServerAuthForConnection(connection, env), + auth: readCliServerAuth(env), }); }; @@ -67,12 +38,14 @@ export const withCliServerAuthFallback = ( ? connection : normalizeExecutorServerConnection({ ...connection, - auth: readCliServerAuthForConnection(connection, env), + auth: readCliServerAuth(env), }); export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { if (connection.kind !== "http") return false; - if (connection.auth?.kind === "basic") return false; + // Explicit auth means the user is pointing at an existing server (e.g. a + // desktop sidecar or a remote), not asking us to spin up a local daemon. + if (connection.auth) return false; const url = new URL(connection.origin); return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); }; diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 801224607..ca6e466fc 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -33,8 +33,8 @@ const BINARY = resolve( process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar", ); -const AUTH_PASSWORD = "smoke-test-password"; -const AUTH_HEADER = `Basic ${btoa(`executor:${AUTH_PASSWORD}`)}`; +const AUTH_TOKEN = "smoke-test-token"; +const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`; const READY_TIMEOUT_MS = 30_000; // Throw instead of process.exit so main()'s finally still tears down the @@ -349,7 +349,7 @@ const main = async () => { ...process.env, EXECUTOR_PORT: "0", EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_PASSWORD: AUTH_PASSWORD, + EXECUTOR_AUTH_TOKEN: AUTH_TOKEN, EXECUTOR_SCOPE_DIR: scopeDir, EXECUTOR_DATA_DIR: dataDir, XDG_DATA_HOME: xdgDir, diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts index ea02eb2b4..f4431af86 100644 --- a/apps/desktop/src/main/diagnostics.ts +++ b/apps/desktop/src/main/diagnostics.ts @@ -197,10 +197,9 @@ const buildManifest = () => { logs: dirname(log.transports.file.getFile().path), crashDumps: app.getPath("crashDumps"), }, - // Redacted on purpose: the Basic-auth password never leaves the machine. + // The bearer token is never included — it stays in auth.json on the machine. serverSettings: { port: settings.port, - requireAuth: settings.requireAuth, }, }; }; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1ab260cde..e4d3d181f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -37,12 +37,11 @@ import { announceBackup, confirmResetState, resetExecutorState } from "./reset-s import { getServerProfiles, getServerSettings, - regeneratePassword, + rotateServerToken, setServerProfiles, updateServerSettings, } from "./settings"; import { - SERVER_SETTINGS_USERNAME, type DesktopServerConnection, type DesktopServerSettings, } from "../shared/server-settings"; @@ -100,15 +99,30 @@ const ensureSingleInstance = () => { return true; }; -const installBasicAuthHeader = (origin: string, password: string | null) => { +const installBearerAuthHeader = (origin: string, token: string | null) => { authHeaderUnsubscribe?.(); authHeaderUnsubscribe = null; - if (!password) return; - const credentials = Buffer.from(`${SERVER_SETTINGS_USERNAME}:${password}`).toString("base64"); - const headerValue = `Basic ${credentials}`; + if (!token) return; + const headerValue = `Bearer ${token}`; session.defaultSession.webRequest.onBeforeSendHeaders( { urls: [`${origin}/*`] }, (details, callback) => { + // Scope the bearer to the app's OWN renderer. OAuth popups run in this same + // session but load third-party provider pages; auto-attaching the bearer to + // any request they make to the sidecar would make it an ambient credential + // (a CSRF vector) for untrusted content — the very thing the bearer model + // exists to avoid. The popup only ever needs the bearer-exempt + // /oauth/callback and hands its result back via same-origin browser + // channels (localStorage/postMessage), so withholding the bearer from any + // non-app webContents is safe. Requests with no webContentsId (main + // process / network service) still get it. + const fromOtherWebContents = + details.webContentsId !== undefined && + (mainWindow === null || details.webContentsId !== mainWindow.webContents.id); + if (fromOtherWebContents) { + callback({ requestHeaders: details.requestHeaders }); + return; + } callback({ requestHeaders: { ...details.requestHeaders, @@ -175,7 +189,7 @@ const createWindow = async (conn: SidecarConnection) => { defaultHeight: 800, }); - installBasicAuthHeader(conn.baseUrl, conn.authPassword); + installBearerAuthHeader(conn.baseUrl, conn.authToken); const linuxIcon = resolveLinuxIcon(); @@ -226,7 +240,7 @@ const createWindow = async (conn: SidecarConnection) => { // No preload, no nodeIntegration — popup loads third-party // OAuth provider pages, then a final navigation back to // 127.0.0.1:/oauth/callback which the session-level - // Basic auth header injection (installBasicAuthHeader) + // bearer header injection (installBearerAuthHeader) // catches automatically. The popup never needs the // executor IPC bridge. contextIsolation: true, @@ -285,43 +299,43 @@ const restartSidecarAndReload = async (): Promise => { throw new Error("Sidecar failed to restart — see Settings"); } connection = next; - installBasicAuthHeader(next.baseUrl, next.authPassword); + installBearerAuthHeader(next.baseUrl, next.authToken); const window = liveMainWindow(); if (window) await window.loadURL(next.baseUrl); return toDesktopServerConnection(next); }; +// The renderer's connection carries NO auth: the main process injects the +// bearer header at the session layer (installBearerAuthHeader), so the token +// never crosses the IPC boundary. const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnection => ({ kind: "desktop-sidecar", key: "desktop-sidecar", origin: conn.baseUrl, apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, displayName: "Desktop sidecar", - ...(conn.authPassword - ? { - auth: { - kind: "basic" as const, - username: SERVER_SETTINGS_USERNAME, - password: conn.authPassword, - }, - } - : {}), }); const registerIpcHandlers = () => { ipcMain.handle("executor:server:connection", (): DesktopServerConnection | null => connection ? toDesktopServerConnection(connection) : null, ); + // The bearer token, exposed only for the "Connect an agent" install command + // (an external agent needs it in plaintext). The renderer's own requests + // never use it — the header is injected at the session layer. + ipcMain.handle("executor:server:auth-token", (): string | null => connection?.authToken ?? null); ipcMain.handle("executor:settings:get", (): DesktopServerSettings => getServerSettings()); ipcMain.handle( "executor:settings:update", (_evt, patch: Partial): DesktopServerSettings => updateServerSettings(patch), ); - ipcMain.handle( - "executor:settings:regenerate-password", - (): DesktopServerSettings => regeneratePassword(), - ); + // Rotate the bearer token (auth.json) and restart the sidecar so it loads the + // new token; the webview header is re-injected by restartSidecarAndReload. + ipcMain.handle("executor:server:rotate-token", (): Promise => { + rotateServerToken(); + return restartSidecarAndReload(); + }); ipcMain.handle("executor:server-profiles:get", (): string | null => getServerProfiles()); ipcMain.handle("executor:server-profiles:set", (_evt, value: unknown): void => { if (typeof value !== "string") return; diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index bc5266e4c..2b3880b40 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -1,5 +1,5 @@ -import { randomBytes } from "node:crypto"; import Store from "electron-store"; +import { rotateLocalAuthToken } from "@executor-js/local"; import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; interface PersistedShape { @@ -7,43 +7,36 @@ interface PersistedShape { readonly serverProfiles?: string; } -const generatePassword = (): string => randomBytes(24).toString("base64url"); - -const seedDefaults = (): DesktopServerSettings => ({ - ...DEFAULT_SERVER_SETTINGS, - password: generatePassword(), -}); - const store = new Store({ name: "settings", - defaults: { server: seedDefaults() }, + defaults: { server: DEFAULT_SERVER_SETTINGS }, }); // Backfill if an older settings.json predates the server section. if (!store.has("server")) { - store.set("server", seedDefaults()); + store.set("server", DEFAULT_SERVER_SETTINGS); } -export const getServerSettings = (): DesktopServerSettings => store.get("server"); +export const getServerSettings = (): DesktopServerSettings => ({ + // Read defensively: older settings.json files carried `requireAuth`/`password` + // fields that no longer exist. Only `port` survives. + port: store.get("server")?.port ?? DEFAULT_SERVER_SETTINGS.port, +}); export const updateServerSettings = ( patch: Partial, ): DesktopServerSettings => { - const current = getServerSettings(); - const next: DesktopServerSettings = { - port: patch.port ?? current.port, - requireAuth: patch.requireAuth ?? current.requireAuth, - password: patch.password ?? current.password, - }; + const next: DesktopServerSettings = { port: patch.port ?? getServerSettings().port }; store.set("server", next); return next; }; -export const regeneratePassword = (): DesktopServerSettings => { - const next = { ...getServerSettings(), password: generatePassword() }; - store.set("server", next); - return next; -}; +/** + * Rotate the local bearer token (auth.json). The caller must restart the + * sidecar so it loads the new token and re-inject the webview header. Returns + * the new token. + */ +export const rotateServerToken = (): string => rotateLocalAuthToken(); export const getServerProfiles = (): string | null => store.get("serverProfiles") ?? null; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 78ea39746..190f9349c 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -5,14 +5,14 @@ * In prod: spawns the Bun-compiled `executor-sidecar` binary shipped under * `process.resourcesPath/sidecar/`. * - * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_PASSWORD + * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN * via env, calls `startServer()` from `@executor-js/local`, and announces a * single sentinel line on stdout (`EXECUTOR_READY:`) so this controller * can resolve the connection promise. */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve, join } from "node:path"; import { app } from "electron"; @@ -23,6 +23,7 @@ import { parseExecutorLocalServerManifest, serializeExecutorLocalServerManifest, } from "@executor-js/sdk/shared"; +import { loadOrMintLocalAuthToken } from "@executor-js/local"; import { getServerSettings } from "./settings"; import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; @@ -66,7 +67,7 @@ export interface SidecarConnection { readonly hostname: string; readonly port: number; readonly username: string; - readonly authPassword: string | null; + readonly authToken: string; readonly child: ChildProcess; } @@ -171,7 +172,7 @@ const writeSidecarManifest = (input: { readonly dataDir: string; readonly scopeDir: string; readonly baseUrl: string; - readonly authPassword: string | null; + readonly authToken: string; readonly childPid: number; }) => { const connection = normalizeExecutorServerConnection({ @@ -179,18 +180,11 @@ const writeSidecarManifest = (input: { key: "desktop-sidecar", origin: input.baseUrl, displayName: "Desktop sidecar", - ...(input.authPassword - ? { - auth: { - kind: "basic" as const, - username: SERVER_SETTINGS_USERNAME, - password: input.authPassword, - }, - } - : {}), + auth: { kind: "bearer" as const, token: input.authToken }, }); + const manifestPath = localServerManifestPath(input.dataDir); writeFileSync( - localServerManifestPath(input.dataDir), + manifestPath, serializeExecutorLocalServerManifest({ version: 1, kind: "desktop-sidecar", @@ -205,7 +199,11 @@ const writeSidecarManifest = (input: { executablePath: process.execPath || null, }, }), + { mode: 0o600 }, ); + // The manifest embeds the bearer token; keep it owner-only even if a looser + // file already existed (writeFileSync's mode does not re-apply on overwrite). + chmodSync(manifestPath, 0o600); sidecarManifestPathByPid.set(input.childPid, input.dataDir); }; @@ -255,7 +253,10 @@ export async function startSidecar(options: StartOptions = {}): Promise { @@ -286,10 +287,9 @@ export async function startSidecar(options: StartOptions = {}): Promise { return ipcRenderer.invoke("executor:server:connection"); }, + /** + * Read the bearer token for the running sidecar. Only used to build the + * "Connect an agent" install command, which an external agent runs and so + * needs the token in plaintext. Returns null when no sidecar is up. + */ + getServerAuthToken(): Promise { + return ipcRenderer.invoke("executor:server:auth-token"); + }, /** Read the desktop-persisted server profile payload. */ getServerProfiles(): Promise { return ipcRenderer.invoke("executor:server-profiles:get"); @@ -14,7 +22,7 @@ const api = { setServerProfiles(value: string): Promise { return ipcRenderer.invoke("executor:server-profiles:set", value); }, - /** Read the persisted server settings (port, requireAuth, password). */ + /** Read the persisted server settings (currently just the port). */ getSettings(): Promise { return ipcRenderer.invoke("executor:settings:get"); }, @@ -22,9 +30,13 @@ const api = { updateSettings(patch: Partial): Promise { return ipcRenderer.invoke("executor:settings:update", patch); }, - /** Regenerate the random Basic-auth password. Returns the new settings. */ - regeneratePassword(): Promise { - return ipcRenderer.invoke("executor:settings:regenerate-password"); + /** + * Rotate the local bearer token and restart the sidecar so it takes effect. + * Returns the refreshed connection. AI-client MCP configs must be re-issued + * with the new token afterwards. + */ + rotateToken(): Promise { + return ipcRenderer.invoke("executor:server:rotate-token"); }, /** * Stop + restart the sidecar so settings changes take effect. diff --git a/apps/desktop/src/shared/server-settings.ts b/apps/desktop/src/shared/server-settings.ts index 6c030f3b6..7a694466f 100644 --- a/apps/desktop/src/shared/server-settings.ts +++ b/apps/desktop/src/shared/server-settings.ts @@ -4,6 +4,11 @@ * * The shape lives in `src/shared/` because both main (IPC handlers) and * renderer (Settings UI + Connect-an-agent surface) need to agree on it. + * + * Auth is NOT a setting: the sidecar always enforces the locally-minted bearer + * token (see `@executor-js/local` auth.json). The main process injects it into + * the webview transparently, so the renderer never sees the credential and + * there is nothing to toggle or persist here. */ import type { ExecutorServerConnection } from "@executor-js/sdk/shared"; @@ -11,33 +16,20 @@ import type { ExecutorServerConnection } from "@executor-js/sdk/shared"; export interface DesktopServerSettings { /** TCP port the sidecar listens on. Default 4789. */ readonly port: number; - /** - * Whether the sidecar enforces HTTP Basic auth on every request. - * When false, the sidecar relies on the host allowlist alone. - */ - readonly requireAuth: boolean; - /** - * Basic auth password the sidecar enforces and the renderer exposes - * via `window.executor`. Persisted across launches so AI client MCP - * configs stay valid until the user regenerates. - */ - readonly password: string; } +/** + * The connection the renderer receives. Auth is intentionally absent — the main + * process injects the bearer header at the session layer, so the credential + * never crosses the IPC boundary. + */ export type DesktopServerConnection = ExecutorServerConnection & { readonly kind: "desktop-sidecar"; readonly key: "desktop-sidecar"; - readonly auth?: { - readonly kind: "basic"; - readonly username: string; - readonly password: string; - }; }; export const DEFAULT_SERVER_SETTINGS: DesktopServerSettings = { port: 4789, - requireAuth: true, - password: "", }; export const SERVER_SETTINGS_USERNAME = "executor"; diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts index 99baac858..8b6ec4e4a 100644 --- a/apps/desktop/src/sidecar/server.ts +++ b/apps/desktop/src/sidecar/server.ts @@ -67,13 +67,16 @@ import { startServer } from "@executor-js/local"; const requestedPort = parseInt(process.env.EXECUTOR_PORT ?? "0", 10); const hostname = process.env.EXECUTOR_HOST ?? "127.0.0.1"; -const authPassword = process.env.EXECUTOR_AUTH_PASSWORD; +// The main process mints/loads the bearer token and threads it in so it can +// inject the same token into the webview. Falls back to the auth.json token +// when absent (e.g. the sidecar booted standalone). +const authToken = process.env.EXECUTOR_AUTH_TOKEN; const clientDir = process.env.EXECUTOR_CLIENT_DIR; const server = await startServer({ port: requestedPort, hostname, - ...(authPassword ? { authPassword } : {}), + ...(authToken ? { authToken } : {}), clientDir, }); diff --git a/apps/local/src/app.ts b/apps/local/src/app.ts index f9bddc6e8..a9915daf5 100644 --- a/apps/local/src/app.ts +++ b/apps/local/src/app.ts @@ -11,7 +11,7 @@ import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { getExecutorBundle, type LocalExecutor } from "./executor"; -import { localIdentityLayer } from "./identity"; +import { makeLocalIdentityLayer } from "./identity"; import { ErrorCaptureLive } from "./observability"; // =========================================================================== @@ -79,7 +79,7 @@ export interface LocalApiHandler { * (no test-only branches), with `serve.ts`'s `handlers` injection hook as the * test seam where a test wants to bypass the boot graph. */ -export const makeLocalApiHandler = async (): Promise => { +export const makeLocalApiHandler = async (token: string): Promise => { const { executor, plugins } = await getExecutorBundle(); // Build the fixed-execution seam ONCE (one executor + one engine). The same @@ -88,12 +88,17 @@ export const makeLocalApiHandler = async (): Promise => { // as self-host declares `db: SelfHostDbProvider` and puts the handle in `boot`. const fixedExecution = localFixedExecutionLayer(executor); + // The authoritative identity gate for the typed `/api`: validates the boot + // bearer token and resolves the one local Principal. The Bun shell + // (`serve.ts`) fast-path-rejects unauthenticated requests with the same token. + const identity = makeLocalIdentityLayer(token); + const { toWebHandler } = ExecutorApp.make({ plugins, providers: { - // Single-user: always resolves the one local Principal (a real impl, not a - // placeholder). Boot-scoped (`RIdentity = never`), captured once. - identity: localIdentityLayer, + // Single-user: validates the boot bearer token and resolves the one local + // Principal. Boot-scoped (`RIdentity = never`), captured once. + identity, // The ONE boot executor + engine, served directly — local's fixed // execution model (no per-request scoped-executor rebuild). fixedExecution, @@ -114,7 +119,7 @@ export const makeLocalApiHandler = async (): Promise => { // The boot-scoped context provideMerge'd under everything: the identity // provider (captured once by the fixed-execution middleware) + the fixed // execution seam (the one executor + engine + extension map). - boot: Layer.merge(localIdentityLayer, fixedExecution), + boot: Layer.merge(identity, fixedExecution), }); const web = toWebHandler(); diff --git a/apps/local/src/auth.ts b/apps/local/src/auth.ts new file mode 100644 index 000000000..506be95c6 --- /dev/null +++ b/apps/local/src/auth.ts @@ -0,0 +1,73 @@ +/** + * The local bearer token — the single auth credential for the local daemon. + * + * Local is single-user: one human, one machine, one executor scoped to the + * working directory. The token is the ONE credential that gates every surface + * (`/api`, `/mcp`, the MCP approval + OAuth await endpoints). It is minted once + * on first run and reused forever so AI-client MCP configs stay valid across + * restarts, and it is the only secret stored at rest. + * + * Storage is a single file, `/server-control/auth.json`, written with + * mode `0600` (owner read/write only). `dataDir` resolves exactly like the + * server manifest path: `EXECUTOR_DATA_DIR` if set, otherwise `~/.executor`. + * This is the source of truth; the runtime server manifest (`server.json`) + * carries a copy of the token for live consumers but does not own it. + * + * Deliberately plain `node:fs` (sync) with no Effect dependency so the Bun + * serve shell (`serve.ts`) and the Electron main process can both call it + * during boot without a runtime. + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; + +/** Resolve the executor data directory — `EXECUTOR_DATA_DIR` or `~/.executor`. */ +export const resolveExecutorDataDir = (): string => + resolve(process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor")); + +const serverControlDir = (dataDir: string): string => join(dataDir, "server-control"); + +/** Absolute path to the auth-token file for a data directory. */ +export const localAuthTokenPath = (dataDir: string = resolveExecutorDataDir()): string => + join(serverControlDir(dataDir), "auth.json"); + +const mintToken = (): string => randomBytes(32).toString("base64url"); + +const readToken = (path: string): string | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: reading an optional on-disk secret file that may be absent or malformed + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the secret file is a tiny {"token"} blob outside the Effect graph (used by the plain Bun/Electron boot path) + const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown }; + return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null; + } catch { + return null; + } +}; + +const writeToken = (dataDir: string, token: string): string => { + const dir = serverControlDir(dataDir); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "auth.json"); + // `mode` only applies when the file is created; chmod afterwards covers the + // case where an older world-readable file already exists. + writeFileSync(path, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + return token; +}; + +/** + * Return the stable local bearer token, minting and persisting one on first + * call. Idempotent: subsequent calls return the same token. + */ +export const loadOrMintLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => + readToken(localAuthTokenPath(dataDir)) ?? writeToken(dataDir, mintToken()); + +/** + * Rotate the local bearer token: mint a fresh one and overwrite the file. + * Callers must re-advertise it (manifest, header injection, MCP client configs) + * and restart any running server so the new token takes effect. + */ +export const rotateLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => + writeToken(dataDir, mintToken()); diff --git a/apps/local/src/identity.test.ts b/apps/local/src/identity.test.ts new file mode 100644 index 000000000..9738ebef2 --- /dev/null +++ b/apps/local/src/identity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; + +import { IdentityProvider } from "@executor-js/api/server"; + +import { LOCAL_PRINCIPAL, makeLocalIdentityLayer } from "./identity"; + +const TOKEN = "boot-token"; + +const authenticate = (path: string, headers: Record = {}) => + Effect.flatMap(IdentityProvider.asEffect(), (provider) => + provider.authenticate(new Request(`http://127.0.0.1${path}`, { headers })), + ).pipe(Effect.provide(makeLocalIdentityLayer(TOKEN)), Effect.exit, Effect.runPromise); + +describe("makeLocalIdentityLayer", () => { + it("resolves the local principal for a matching bearer token", async () => { + const exit = await authenticate("/integrations", { authorization: `Bearer ${TOKEN}` }); + expect(exit).toStrictEqual(Exit.succeed(LOCAL_PRINCIPAL)); + }); + + it("fails Unauthorized with no bearer token", async () => { + const exit = await authenticate("/integrations"); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("fails Unauthorized with the wrong bearer token", async () => { + const exit = await authenticate("/integrations", { authorization: "Bearer wrong" }); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("allows the OAuth callback without a bearer (state-gated)", async () => { + // The Bun shell strips the `/api` prefix before this layer runs. + const exit = await authenticate("/oauth/callback?state=abc"); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + it("still requires a bearer on the OAuth await poll", async () => { + const exit = await authenticate("/oauth/await/session-1"); + expect(Exit.isFailure(exit)).toBe(true); + }); +}); diff --git a/apps/local/src/identity.ts b/apps/local/src/identity.ts index 6944d23d0..562d96dc7 100644 --- a/apps/local/src/identity.ts +++ b/apps/local/src/identity.ts @@ -1,6 +1,8 @@ import { Effect, Layer } from "effect"; -import { IdentityProvider, type Principal } from "@executor-js/api/server"; +import { IdentityProvider, type Principal, Unauthorized } from "@executor-js/api/server"; + +import { safeEqual } from "./serve-shared"; // --------------------------------------------------------------------------- // The local identity seam — the production implementation of the shared @@ -9,42 +11,74 @@ import { IdentityProvider, type Principal } from "@executor-js/api/server"; // // Local is single-user: there is no account/org directory, and the executor it // serves is a single boot-built instance scoped to the working directory (see -// `FixedExecutionProvider` in `app.ts`). So this provider ALWAYS resolves the -// one local Principal — there is no credential lookup to perform here. (The -// optional process-level Basic/Bearer gate that protects a network bind lives in -// the Bun serve shell, `serve.ts`; it is a coarse network gate, not request -// identity, and stays separate.) +// `FixedExecutionProvider` in `app.ts`). There is exactly ONE credential — the +// locally-minted bearer token (see `auth.ts`) — and exactly ONE Principal. So +// this provider validates the `Authorization: Bearer ` header against the +// boot token and resolves the one local Principal, or fails `Unauthorized`. // -// This is a genuine implementation, not a placeholder: `authenticate` returns a -// concrete, stable `Principal` whose `AuthContext` the executor API handlers -// read. The fixed executor ignores the `accountId`/`organizationId` (it does NOT -// rebuild a per-(user, org) scope the way cloud/self-host do), so these values -// only populate `AuthContext` for handlers/telemetry that surface "who am I". +// This is the authoritative gate for the typed `/api` (the shared +// `ExecutionStackMiddleware` calls `authenticate(request)` with the Web +// `Request`). The Bun serve shell (`serve.ts`) additionally fast-path-rejects +// unauthenticated requests and is the gate for the non-typed local surfaces +// (`/mcp`, the MCP approval endpoint, the OAuth await poll) that never reach +// this middleware — both read the SAME boot token. // --------------------------------------------------------------------------- /** - * The single local Principal every request resolves to. Stable across the - * process; the `local` ids identify the single-user daemon in `AuthContext` and - * any "me"-style surfaces. The fixed executor's scope is cwd-derived (in - * `app.ts`), independent of these ids. + * The single local Principal every authenticated request resolves to. Stable + * across the process; the `local` ids identify the single-user daemon in + * `AuthContext` and any "me"-style surfaces. The fixed executor's scope is + * cwd-derived (in `app.ts`), independent of these ids. */ export const LOCAL_PRINCIPAL: Principal = { accountId: "local", organizationId: "local", organizationName: "Local", - email: "", - name: null, + email: "local@localhost", + name: "Local", avatarUrl: null, roles: [], }; +const bearerToken = (headers: Headers): string | undefined => { + const authorization = headers.get("authorization"); + if (!authorization) return undefined; + return authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() || undefined + : undefined; +}; + +// The OAuth provider callback is hit by the user's external browser, which +// can't carry our bearer — the OAuth `state` (validated downstream by +// completeOAuth) is the security gate. The Bun shell strips the `/api` prefix +// before this layer runs, so the path here is `/oauth/callback`. This mirrors +// the shell's `isUnauthenticatedOAuthCallbackPath` exemption (cloud/self-host +// instead authenticate the callback via the same-origin session cookie). +const isUnauthenticatedCallback = (request: Request): boolean => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: parsing a request URL that should always be valid here + try { + return /\/oauth\/callback(\/|$)/.test(new URL(request.url).pathname); + } catch { + return false; + } +}; + /** - * The local `IdentityProvider`: always resolves `LOCAL_PRINCIPAL`. A complete - * `Layer` with no residual requirement (`RIdentity = never`), - * so the facade captures it once at boot like self-host's. + * Build the local `IdentityProvider`: validate the request's bearer token + * against the boot token and resolve `LOCAL_PRINCIPAL`, else fail `Unauthorized` + * (rendered as a 401 by the middleware's failure strategy). A complete + * `Layer` with no residual requirement, so the facade captures + * it once at boot like self-host's. */ -export const localIdentityLayer: Layer.Layer = Layer.succeed(IdentityProvider)( - IdentityProvider.of({ - authenticate: () => Effect.succeed(LOCAL_PRINCIPAL), - }), -); +export const makeLocalIdentityLayer = (token: string): Layer.Layer => + Layer.succeed(IdentityProvider)( + IdentityProvider.of({ + authenticate: (request) => { + if (isUnauthenticatedCallback(request)) return Effect.succeed(LOCAL_PRINCIPAL); + const presented = bearerToken(request.headers); + return presented !== undefined && safeEqual(presented, token) + ? Effect.succeed(LOCAL_PRINCIPAL) + : Effect.fail(new Unauthorized()); + }, + }), + ); diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts index 1f759296f..39632cc17 100644 --- a/apps/local/src/index.ts +++ b/apps/local/src/index.ts @@ -14,3 +14,4 @@ export { } from "./executor"; export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; export { startServer, type StartServerOptions, type ServerInstance } from "./serve"; +export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth"; diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index bce001a3a..b773d7aad 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -53,9 +53,10 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise => { ); }; -export const createServerHandlers = async (): Promise => { - // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). - const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(); +export const createServerHandlers = async (token: string): Promise => { + // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The + // boot bearer token is the authoritative `/api` gate (see `identity.ts`). + const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token); // The in-process MCP server runs over the SAME boot executor, with its own // engine instance (the browser-approval + stdio surface is local-only and not @@ -75,22 +76,36 @@ export class ServerHandlersService extends Context.Service createServerHandlers()), - (handlers) => Effect.promise(() => closeServerHandlers(handlers)), - ), -); +// The handlers are built once per process and memoized. The boot token is +// captured on the first call (serve.ts / the vite dev middleware both pass the +// SAME token loaded from `auth.json`), so memoization on first-call is correct. +let serverHandlersRuntime: ManagedRuntime.ManagedRuntime | null = + null; -const serverHandlersRuntime = ManagedRuntime.make(ServerHandlersLive); +const getServerHandlersRuntime = ( + token: string, +): ManagedRuntime.ManagedRuntime => { + if (serverHandlersRuntime) return serverHandlersRuntime; + const layer = Layer.effect(ServerHandlersService)( + Effect.acquireRelease( + Effect.promise(() => createServerHandlers(token)), + (handlers) => Effect.promise(() => closeServerHandlers(handlers)), + ), + ); + serverHandlersRuntime = ManagedRuntime.make(layer); + return serverHandlersRuntime; +}; -export const getServerHandlers = (): Promise => - serverHandlersRuntime.runPromise(ServerHandlersService.asEffect()); +export const getServerHandlers = (token: string): Promise => + getServerHandlersRuntime(token).runPromise(ServerHandlersService.asEffect()); export const disposeServerHandlers = async (): Promise => { + const runtime = serverHandlersRuntime; + if (!runtime) return; + serverHandlersRuntime = null; await Effect.runPromise( Effect.tryPromise({ - try: () => serverHandlersRuntime.dispose(), + try: () => runtime.dispose(), catch: (cause) => cause, }).pipe(Effect.ignore), ); diff --git a/apps/local/src/serve-shared.test.ts b/apps/local/src/serve-shared.test.ts new file mode 100644 index 000000000..8b5eb1dcd --- /dev/null +++ b/apps/local/src/serve-shared.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + DEFAULT_ALLOWED_HOSTS, + hasBearerToken, + isAllowedOrigin, + isUnauthenticatedOAuthCallbackPath, + makeIsAuthorized, +} from "./serve-shared"; + +const allowed = new Set(DEFAULT_ALLOWED_HOSTS); +const req = (headers: Record): Request => + new Request("http://127.0.0.1/api/scope", { headers }); + +describe("isAllowedOrigin", () => { + it("allows loopback origins on any port", () => { + expect(isAllowedOrigin("http://127.0.0.1:4789", allowed)).toBe(true); + expect(isAllowedOrigin("http://localhost:5173", allowed)).toBe(true); + }); + + it("rejects foreign and malformed origins", () => { + expect(isAllowedOrigin("https://evil.example", allowed)).toBe(false); + expect(isAllowedOrigin("not-a-url", allowed)).toBe(false); + }); +}); + +describe("makeIsAuthorized", () => { + const isAuthorized = makeIsAuthorized("secret-token"); + + it("accepts a matching bearer token", () => { + expect(isAuthorized(req({ authorization: "Bearer secret-token" }))).toBe(true); + expect(hasBearerToken(req({ authorization: "bearer secret-token" }), "secret-token")).toBe( + true, + ); + }); + + it("rejects a missing or wrong token", () => { + expect(isAuthorized(req({}))).toBe(false); + expect(isAuthorized(req({ authorization: "Bearer wrong" }))).toBe(false); + expect(isAuthorized(req({ authorization: "Basic secret-token" }))).toBe(false); + }); +}); + +describe("isUnauthenticatedOAuthCallbackPath", () => { + it("exempts only the callback path, not the await poll", () => { + expect(isUnauthenticatedOAuthCallbackPath("/api/oauth/callback")).toBe(true); + expect(isUnauthenticatedOAuthCallbackPath("/api/oauth/callback/x")).toBe(true); + expect(isUnauthenticatedOAuthCallbackPath("/api/oauth/await/session-1")).toBe(false); + expect(isUnauthenticatedOAuthCallbackPath("/api/scope")).toBe(false); + }); +}); diff --git a/apps/local/src/serve-shared.ts b/apps/local/src/serve-shared.ts index de8fc0c58..b6d159765 100644 --- a/apps/local/src/serve-shared.ts +++ b/apps/local/src/serve-shared.ts @@ -1,9 +1,22 @@ /** - * Auth, host-allow, and static-route helpers shared between the Bun listener - * (serve.ts) and the Node listener used by the desktop sidecar (serve-node.ts). + * Auth, CORS, and static-route helpers for the local Bun listener (serve.ts). + * + * Local has ONE credential: the locally-minted bearer token (see auth.ts), and + * that is the entire security boundary. There is deliberately no Host allowlist + * (DNS-rebinding defense): a cross-origin page can't read the token — it lives + * in the real origin's storage and is sent as an explicit header, never an + * ambient cookie — so it can't forge an authenticated request no matter which + * host it connects through. Dropping the Host gate is what lets a local + * instance be reached over a tailnet (or any hostname) with no extra flag. + * These helpers express the bearer gate, the CORS origin allowlist, and the + * single unauthenticated OAuth-callback carve-out. */ import { timingSafeEqual } from "node:crypto"; +/** + * Loopback hostnames granted credentialed CORS access by default (any port). + * This is the only place hostnames are matched — for CORS, not for a Host gate. + */ export const DEFAULT_ALLOWED_HOSTS: ReadonlyArray = [ "localhost", "127.0.0.1", @@ -11,8 +24,6 @@ export const DEFAULT_ALLOWED_HOSTS: ReadonlyArray = [ "::1", ]; -const LOOPBACK_BIND_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); - export const normalizeCredential = (value: string | undefined): string | null => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : null; @@ -24,55 +35,38 @@ export const safeEqual = (actual: string, expected: string): boolean => { return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); }; -export const isLoopbackBindHost = (hostname: string): boolean => - LOOPBACK_BIND_HOSTS.has(hostname.trim().toLowerCase()); - -export const makeIsAllowedHost = - (allowed: ReadonlySet) => - (request: Request): boolean => { - const host = request.headers.get("host"); - if (!host) return true; - const hostname = host.replace(/:\d+$/, ""); - return allowed.has(hostname); - }; - -export const hasBearerToken = (request: Request, token: string): boolean => { - const authorization = request.headers.get("authorization"); - const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); - return ( - (bearer !== undefined && safeEqual(bearer, token)) || - safeEqual(request.headers.get("x-executor-token") ?? "", token) - ); -}; - -export const hasBasicPassword = (request: Request, password: string): boolean => { - const authorization = request.headers.get("authorization"); - const encoded = authorization?.match(/^Basic\s+(.+)$/i)?.[1]?.trim(); - if (!encoded) return false; - - let decoded: string; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Basic auth decoding accepts untrusted header bytes +const hostnameFromOrigin = (origin: string): string | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: parsing an untrusted Origin header that may be malformed try { - decoded = Buffer.from(encoded, "base64").toString("utf8"); + return new URL(origin).hostname; } catch { - return false; + return null; } +}; - const separator = decoded.indexOf(":"); - const actualPassword = separator >= 0 ? decoded.slice(separator + 1) : decoded; - return safeEqual(actualPassword, password); +/** + * Whether a cross-origin request's `Origin` is allowed CORS access. Only the + * loopback host allowlist (any port) plus operator-added hosts qualify — never + * a reflected arbitrary origin. This is what keeps `Allow-Credentials: true` + * from handing an authenticated cross-origin channel to any web page. + */ +export const isAllowedOrigin = (origin: string, allowed: ReadonlySet): boolean => { + const hostname = hostnameFromOrigin(origin); + if (hostname === null) return false; + return allowed.has(hostname) || allowed.has(`[${hostname}]`); }; -export interface AuthCredentials { - readonly token: string | null; - readonly password: string | null; -} +export const hasBearerToken = (request: Request, token: string): boolean => { + const authorization = request.headers.get("authorization"); + const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); + return bearer !== undefined && safeEqual(bearer, token); +}; +/** The request gate: a valid `Authorization: Bearer ` header. */ export const makeIsAuthorized = - (auth: AuthCredentials) => + (token: string) => (request: Request): boolean => - (auth.token !== null && hasBearerToken(request, auth.token)) || - (auth.password !== null && hasBasicPassword(request, auth.password)); + hasBearerToken(request, token); export const hasFileExtension = (pathname: string): boolean => { const lastSegment = pathname.split("/").at(-1) ?? ""; @@ -80,18 +74,15 @@ export const hasFileExtension = (pathname: string): boolean => { }; /** - * OAuth provider callbacks land here from the user's external browser, - * which has no way to send our Basic auth header. The `state` parameter - * is the cryptographic gate — each in-flight session is server-issued - * and validated by the shared `completeOAuth` before any work happens. - * Bypassing Basic auth on these paths is safe. + * OAuth provider callbacks land here from the user's external browser, which + * has no way to send our bearer header. The `state` parameter is the + * cryptographic gate — each in-flight session is server-issued and validated by + * the shared `completeOAuth` before any work happens. Bypassing the bearer on + * this ONE path is safe. * - * Matches: - * - `/api/oauth/callback` — the shared OAuth API mount point. - * - `/api/oauth/await/` — polled by the Electron renderer - * when the user runs the flow in their system browser. The sessionId - * is the cryptographic flow id; results are one-shot, so a leaked - * poll without a matching active flow returns null. + * Note: the result-polling path (`/api/oauth/await/`) is NOT exempt — + * it is polled by our own renderer, which carries the bearer (the desktop + * webview injects it; the web SPA sends it from its stored token). */ export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean => - /^\/api\/oauth\/(callback|await)(\/|$)/.test(pathname); + /^\/api\/oauth\/callback(\/|$)/.test(pathname); diff --git a/apps/local/src/serve.test.ts b/apps/local/src/serve.test.ts index 4786abe33..43ff04445 100644 --- a/apps/local/src/serve.test.ts +++ b/apps/local/src/serve.test.ts @@ -1,36 +1,47 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { startServer, type ServerInstance } from "./serve"; let clientDir: string; +let dataDir: string; let server: ServerInstance | null = null; -const startTestServer = async (): Promise => { +const TOKEN = "test-token"; + +const testHandlers = () => ({ + api: { + handler: async () => new Response("ok"), + dispose: async () => {}, + }, + mcp: { + handleRequest: async () => new Response("ok"), + handleApprovalRequest: async () => new Response("ok"), + handlePausedRequest: async () => new Response("ok"), + close: async () => {}, + }, +}); + +const startTestServer = async ( + opts: { authToken?: string; hostname?: string } = {}, +): Promise => { server = await startServer({ port: 0, - hostname: "127.0.0.1", + hostname: opts.hostname ?? "127.0.0.1", clientDir, - handlers: { - api: { - handler: async () => new Response("ok"), - dispose: async () => {}, - }, - mcp: { - handleRequest: async () => new Response("ok"), - handleApprovalRequest: async () => new Response("ok"), - handlePausedRequest: async () => new Response("ok"), - close: async () => {}, - }, - }, + authToken: opts.authToken ?? TOKEN, + handlers: testHandlers(), }); return `http://127.0.0.1:${server.port}`; }; beforeEach(() => { clientDir = mkdtempSync(join(tmpdir(), "exec-local-serve-")); + dataDir = mkdtempSync(join(tmpdir(), "exec-local-data-")); + // Isolate auth.json writes from the real ~/.executor. + process.env.EXECUTOR_DATA_DIR = dataDir; mkdirSync(join(clientDir, "assets"), { recursive: true }); writeFileSync( join(clientDir, "index.html"), @@ -44,10 +55,12 @@ afterEach(async () => { await server.stop(); server = null; } + delete process.env.EXECUTOR_DATA_DIR; rmSync(clientDir, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); }); -describe("startServer static/SPA routing", () => { +describe("startServer static/SPA routing (unauthenticated)", () => { it("returns 404 for missing asset-like paths", async () => { const baseUrl = await startTestServer(); const response = await fetch(`${baseUrl}/assets/missing.js`); @@ -56,8 +69,10 @@ describe("startServer static/SPA routing", () => { expect(await response.text()).toBe("Not Found"); }); - it("falls back to index.html for extension-less SPA routes", async () => { + it("falls back to index.html for extension-less SPA routes without a token", async () => { const baseUrl = await startTestServer(); + // No Authorization header — the shell must still load so the browser can + // read its `?_token` and authenticate subsequent /api calls. const response = await fetch(`${baseUrl}/sources/add`); expect(response.status).toBe(200); @@ -66,133 +81,108 @@ describe("startServer static/SPA routing", () => { }); }); -describe("startServer network bind auth", () => { - it("refuses non-loopback binds without a token or password", async () => { - await expect( - startServer({ - port: 0, - hostname: "0.0.0.0", - clientDir, - handlers: { - api: { - handler: async () => new Response("ok"), - dispose: async () => {}, - }, - mcp: { - handleRequest: async () => new Response("ok"), - handleApprovalRequest: async () => new Response("ok"), - handlePausedRequest: async () => new Response("ok"), - close: async () => {}, - }, - }, - }), - ).rejects.toThrow("non-loopback host without an auth token or password"); +describe("startServer bearer auth", () => { + it("mints and persists a 0600 auth.json when no token is supplied", async () => { + server = await startServer({ port: 0, clientDir, handlers: testHandlers() }); + const tokenPath = join(dataDir, "server-control", "auth.json"); + expect(existsSync(tokenPath)).toBe(true); + // Owner read/write only. + expect(statSync(tokenPath).mode & 0o777).toBe(0o600); + expect(typeof server.authToken).toBe("string"); + expect(server.authToken.length).toBeGreaterThan(0); }); - it("requires the configured token when auth is enabled", async () => { - server = await startServer({ - port: 0, - hostname: "127.0.0.1", - clientDir, - authToken: "test-token", - handlers: { - api: { - handler: async () => new Response("ok"), - dispose: async () => {}, - }, - mcp: { - handleRequest: async () => new Response("ok"), - handleApprovalRequest: async () => new Response("ok"), - handlePausedRequest: async () => new Response("ok"), - close: async () => {}, - }, - }, - }); + it("serves /api/health without a token", async () => { + const baseUrl = await startTestServer(); + const response = await fetch(`${baseUrl}/api/health`); + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); + }); + + it("requires the bearer token on /api", async () => { + const baseUrl = await startTestServer(); - const baseUrl = `http://127.0.0.1:${server.port}`; - const unauthorized = await fetch(`${baseUrl}/api/health`); + const unauthorized = await fetch(`${baseUrl}/api/scope`); expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("www-authenticate")).toBe('Bearer realm="executor"'); - const authorized = await fetch(`${baseUrl}/api/health`, { - headers: { authorization: "Bearer test-token" }, + const authorized = await fetch(`${baseUrl}/api/scope`, { + headers: { authorization: `Bearer ${TOKEN}` }, }); expect(authorized.status).toBe(200); expect(await authorized.text()).toBe("ok"); }); - it("answers browser CORS preflights before auth", async () => { - server = await startServer({ - port: 0, - hostname: "127.0.0.1", - clientDir, - authToken: "test-token", - handlers: { - api: { - handler: async () => new Response("ok"), - dispose: async () => {}, - }, - mcp: { - handleRequest: async () => new Response("ok"), - handleApprovalRequest: async () => new Response("ok"), - handlePausedRequest: async () => new Response("ok"), - close: async () => {}, - }, - }, - }); + it("requires the bearer token on /mcp", async () => { + const baseUrl = await startTestServer(); - const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, { - method: "OPTIONS", - headers: { - origin: "http://127.0.0.1:4789", - "access-control-request-method": "GET", - "access-control-request-headers": "authorization,b3,traceparent", - }, + const unauthorized = await fetch(`${baseUrl}/mcp`, { method: "POST" }); + expect(unauthorized.status).toBe(401); + + const authorized = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}` }, }); + expect(authorized.status).toBe(200); + }); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789"); - expect(response.headers.get("access-control-allow-credentials")).toBe("true"); - expect(response.headers.get("access-control-allow-headers")).toContain("traceparent"); - expect(response.headers.get("access-control-allow-headers")).toContain("b3"); + it("leaves the OAuth provider callback unauthenticated (state-gated)", async () => { + const baseUrl = await startTestServer(); + // Reaches the api handler ("ok") rather than a 401 — the callback path is + // exempt because the external provider browser can't carry the bearer. + const response = await fetch(`${baseUrl}/api/oauth/callback?state=abc`); + expect(response.status).toBe(200); }); - it("adds CORS headers to authenticated API failures", async () => { + it("requires the bearer token on the OAuth await poll", async () => { + const baseUrl = await startTestServer(); + const response = await fetch(`${baseUrl}/api/oauth/await/session-1`); + expect(response.status).toBe(401); + }); + + it("auto-mints a token on a non-loopback bind instead of refusing", async () => { server = await startServer({ port: 0, - hostname: "127.0.0.1", + hostname: "0.0.0.0", clientDir, - authToken: "test-token", - handlers: { - api: { - handler: async () => new Response("ok"), - dispose: async () => {}, - }, - mcp: { - handleRequest: async () => new Response("ok"), - handleApprovalRequest: async () => new Response("ok"), - handlePausedRequest: async () => new Response("ok"), - close: async () => {}, - }, - }, + handlers: testHandlers(), }); + expect(server.authToken.length).toBeGreaterThan(0); + }); +}); - const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, { +describe("startServer CORS hardening", () => { + it("reflects credentialed CORS only for allowed loopback origins", async () => { + const baseUrl = await startTestServer(); + const response = await fetch(`${baseUrl}/api/health`, { headers: { origin: "http://127.0.0.1:4789" }, }); - - expect(response.status).toBe(401); expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789"); expect(response.headers.get("access-control-allow-credentials")).toBe("true"); - expect(response.headers.get("access-control-allow-headers")).toContain("traceparent"); }); - it("does not expose unauthenticated loopback servers cross-origin", async () => { + it("does not send CORS headers to a disallowed origin", async () => { + const baseUrl = await startTestServer(); + const response = await fetch(`${baseUrl}/api/health`, { + headers: { origin: "https://evil.example" }, + }); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("answers browser CORS preflights before auth", async () => { const baseUrl = await startTestServer(); const response = await fetch(`${baseUrl}/api/scope`, { - headers: { origin: "https://example.com" }, + method: "OPTIONS", + headers: { + origin: "http://127.0.0.1:4789", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization,b3,traceparent", + }, }); - expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789"); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + expect(response.headers.get("access-control-allow-headers")).toContain("traceparent"); }); }); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 2fd49ff0c..1caa824b0 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -11,15 +11,15 @@ import { resolve, join } from "node:path"; import { readdirSync } from "node:fs"; import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; +import { loadOrMintLocalAuthToken } from "./auth"; import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; import { startIntegrationsRefresh } from "./integrations"; import { getServerHandlers } from "./main"; import { DEFAULT_ALLOWED_HOSTS, hasFileExtension, - isLoopbackBindHost, + isAllowedOrigin, isUnauthenticatedOAuthCallbackPath, - makeIsAllowedHost, makeIsAuthorized, normalizeCredential, } from "./serve-shared"; @@ -183,18 +183,29 @@ export interface StartServerOptions { embeddedWebUI?: Record | null; /** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */ hostname?: string; - /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */ + /** + * Extra origins granted credentialed CORS access, on top of localhost/ + * 127.0.0.1 (any port). There is no Host allowlist — the bearer token is the + * security boundary — so reaching the server from another host (e.g. over a + * tailnet) needs nothing here; this only widens cross-origin CORS. + */ allowedHosts?: ReadonlyArray; - /** Bearer token required for requests. Required for non-loopback bind addresses. */ + /** + * Bearer token required for every `/api` and `/mcp` request. Optional — when + * omitted the stable token is loaded from (or minted into) `auth.json`. Pass + * an explicit value to override (e.g. the desktop main process threads its + * own token to the sidecar child). + */ authToken?: string; - /** Basic auth password required for requests. Required for non-loopback bind addresses. */ - authPassword?: string; /** Test hook for supplying API/MCP handlers without loading the local server graph. */ handlers?: ServerHandlers; } export interface ServerInstance { port: number; + /** The effective bearer token this server validates. Callers publish it in the + * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */ + authToken: string; stop: () => Promise; } @@ -208,9 +219,17 @@ const corsHeaders = { "access-control-expose-headers": "*", } as const; -const withCorsHeaders = (req: Request, response: Response): Response => { +const withCorsHeaders = ( + req: Request, + response: Response, + allowedHosts: ReadonlySet, +): Response => { const origin = req.headers.get("origin"); - if (!origin) return response; + // Same-origin requests carry no Origin header — nothing to do. Cross-origin + // requests only get credentialed CORS if their Origin is an allowed loopback + // host; an arbitrary web page (e.g. https://evil.example) gets no ACAO, so + // the browser blocks it reading the response even if it knew the token. + if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); @@ -227,30 +246,28 @@ const withCorsHeaders = (req: Request, response: Response): Response => { }); }; -const corsPreflightResponse = (req: Request): Response => - withCorsHeaders(req, new Response(null, { status: 204 })); +const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet): Response => + withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); export async function startServer(opts: StartServerOptions = {}): Promise { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; - const auth = { - token: normalizeCredential(opts.authToken), - password: normalizeCredential(opts.authPassword), - }; - const isNetworkBind = !isLoopbackBindHost(hostname); - const requiresAuth = auth.token !== null || auth.password !== null; - if (isNetworkBind && !requiresAuth) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: startServer is a Promise API and rejects invalid bind options - throw new Error("Refusing to listen on a non-loopback host without an auth token or password."); - } - const isAuthorized = makeIsAuthorized(auth); - const allowedHostSet = new Set([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]); - const isAllowedHost = makeIsAllowedHost(allowedHostSet); + // ONE credential, always present: an explicit override or the stable token + // from auth.json (minted on first run). Auth is unconditionally on — loopback + // is no longer a free pass, since Executor runs arbitrary code that any local + // process could otherwise drive. + const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); + const isAuthorized = makeIsAuthorized(authToken); + // CORS-only origin allowlist (no Host gate — the bearer is the boundary). + const corsAllowedHosts = new Set([ + ...DEFAULT_ALLOWED_HOSTS, + ...(opts.allowedHosts ?? []), + ]); const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist"); startIntegrationsRefresh(); - const handlers = opts.handlers ?? (await getServerHandlers()); + const handlers = opts.handlers ?? (await getServerHandlers(authToken)); // Mirror every OAuth callback completion into the local in-memory result // store. The Electron desktop renderer polls /api/oauth/await/:sessionId @@ -271,7 +288,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise // Unused when viteChild is non-null; defined so the type checker // can keep `serveIndex` non-nullable. @@ -294,43 +315,49 @@ export async function startServer(opts: StartServerOptions = {}): Promise - requiresAuth ? withCorsHeaders(req, response) : response; - - if (!isAllowedHost(req)) { - return maybeWithCorsHeaders(new Response("Forbidden", { status: 403 })); - } + const withCors = (response: Response): Response => + withCorsHeaders(req, response, corsAllowedHosts); - if (requiresAuth && req.method === "OPTIONS" && req.headers.has("origin")) { - return corsPreflightResponse(req); + if (req.method === "OPTIONS" && req.headers.has("origin")) { + return corsPreflightResponse(req, corsAllowedHosts); } const url = new URL(req.url); - // OAuth provider callbacks are hit by the user's external browser - // and can't carry our Basic auth header. The OAuth `state` - // parameter is the security gate — see isUnauthenticatedOAuthCallbackPath. + // Unauthenticated liveness probe — carries no data, used by the CLI + // reachability check (which therefore never forwards a credential). + if (url.pathname === "/api/health" && req.method === "GET") { + return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); + } + + // OAuth provider callbacks are hit by the user's external browser and + // can't carry our bearer. The OAuth `state` parameter is the security + // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under + // /api and /mcp requires the bearer. const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname); + const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp"); - if (requiresAuth && !skipAuth && !isAuthorized(req)) { - return maybeWithCorsHeaders( + if (isGatedSurface && !skipAuth && !isAuthorized(req)) { + return withCors( new Response("Unauthorized", { status: 401, - headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' }, + headers: { "www-authenticate": 'Bearer realm="executor"' }, }), ); } if (url.pathname.startsWith("/mcp")) { - return maybeWithCorsHeaders(await handlers.mcp.handleRequest(req)); + return withCors(await handlers.mcp.handleRequest(req)); } if (url.pathname.startsWith("/api/mcp-sessions/")) { + // GET → paused-execution detail for the approval page; POST → record the + // decision. Both are bearer-gated above. const handler = req.method === "GET" ? handlers.mcp.handlePausedRequest : handlers.mcp.handleApprovalRequest; - return maybeWithCorsHeaders(await handler(req)); + return withCors(await handler(req)); } // OAuth result polling — local-only, served outside the typed API @@ -339,7 +366,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise { + const address = server.httpServer?.address(); + const port = + typeof address === "object" && address ? address.port : server.config.server.port; + server.config.logger.info( + `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`, + ); + }); + } + server.watcher.on("change", (path) => { if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) { handlers = null; @@ -52,11 +76,36 @@ function executorApiPlugin(): Plugin { if (!isApi && !isMcp) return next(); + // Gate parity with the production Bun shell (serve.ts): the vite server + // is reachable by any local process, so /api and /mcp require the bearer + // here too — otherwise /mcp would be unauthenticated arbitrary code + // execution in dev. Exempt the health probe and the state-gated OAuth + // callback. The SPA carries the token from its `?_token`/localStorage + // bootstrap, so the UI is unaffected; external MCP clients use the + // daemon port. + const pathOnly = rawUrl.split("?")[0] ?? "/"; + const authExempt = + pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); + if (!authExempt) { + const presented = req.headers.authorization; + const authValue = Array.isArray(presented) ? presented[0] : presented; + const probe = new Request( + "http://localhost/", + authValue ? { headers: { authorization: authValue } } : undefined, + ); + if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) { + res.statusCode = 401; + res.setHeader("www-authenticate", 'Bearer realm="executor"'); + res.end("Unauthorized"); + return; + } + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite middleware must convert handler failures into HTTP 500 responses try { if (!handlers) { const { getServerHandlers } = await import("./src/main"); - handlers = await getServerHandlers(); + handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken()); } const origin = `http://${req.headers.host ?? "localhost"}`; diff --git a/e2e/desktop/local-auth-mcp.test.ts b/e2e/desktop/local-auth-mcp.test.ts new file mode 100644 index 000000000..a0ca14ee5 --- /dev/null +++ b/e2e/desktop/local-auth-mcp.test.ts @@ -0,0 +1,255 @@ +// Desktop-only: the local bearer-auth model through the REAL Electron app + +// sidecar, covering the two flows a code review found the e2e suite missed: +// +// A. MCP BROWSER APPROVAL (guards the resume-page bearer): an MCP client hits +// a gated tool against the desktop's sidecar; the user approves in the +// renderer; the agent's resume completes. This drives the whole desktop +// bearer path — the renderer carries NO client-side token, so every +// /api/mcp-sessions call (the session-scoped paused GET + the resume POST) +// relies on the main process injecting the bearer at the session layer. +// +// B. BEARER SCOPING (guards against the ambient-credential regression): a +// NON-app BrowserWindow (what an OAuth popup is) issuing a request to the +// sidecar's gated /api must NOT get the bearer auto-attached — it 401s — +// while the app's own window does. Pre-fix the injection covered every +// webContents in the session, so a popup could ride the bearer (CSRF). +// +// Both launch the real app via `_electron.launch` against a throwaway HOME and +// read the sidecar's origin + bearer from the on-disk manifest. +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { _electron, type ElectronApplication } from "playwright"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; + +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; + +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +interface SidecarConn { + readonly origin: string; + readonly token: string; +} + +const launchDesktop = async (home: string, runDir: string): Promise => + _electron.launch({ + executablePath: electronBinary, + args: [appDir], + cwd: appDir, + env: { ...process.env, HOME: home }, + recordVideo: { dir: join(runDir, ".video-tmp"), size: { width: 1280, height: 800 } }, + timeout: 120_000, + }); + +// The sidecar writes server.json (origin + bearer) once it emits EXECUTOR_READY, +// which is exactly when firstWindow resolves — so this is always safe to read +// after the window is up. +const readSidecar = (home: string): SidecarConn => { + const manifest = JSON.parse( + readFileSync(join(home, ".executor/server-control/server.json"), "utf8"), + ) as { connection: { origin: string; auth?: { token?: string } } }; + const origin = manifest.connection.origin; + const token = manifest.connection.auth?.token; + expect(typeof origin, "sidecar origin in the manifest").toBe("string"); + expect(typeof token, "sidecar bearer token in the manifest").toBe("string"); + return { origin, token: token! }; +}; + +const closeWithVideo = async (app: ElectronApplication, runDir: string, home: string) => { + const page = app.windows()[0]; + const video = page?.video(); + await app.close().catch(() => {}); + const recordedPath = await video?.path().catch(() => undefined); + if (recordedPath && existsSync(recordedPath)) { + await promisify(execFile)("ffmpeg", [ + "-y", + "-i", + recordedPath, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "26", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + join(runDir, "session.mp4"), + ]).catch(() => {}); + } + rmSync(join(runDir, ".video-tmp"), { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}; + +// --------------------------------------------------------------------------- +// A. MCP browser approval through the desktop app +// --------------------------------------------------------------------------- + +scenario( + "Desktop · MCP browser approval: gated tool, approve in the app, resume completes", + { timeout: 300_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => runApproval(runDir)); + }), +); + +const runApproval = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-desktop-mcp-")); + const app = await launchDesktop(home, runDir); + let stepIndex = 0; + try { + const page = await app.firstWindow({ timeout: 120_000 }); + const step = async (label: string, body: () => Promise) => { + await body(); + stepIndex += 1; + await page.screenshot({ + path: join( + runDir, + `${String(stepIndex).padStart(2, "0")}-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png`, + ), + }); + }; + + await step("app boots into the web console", async () => { + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + }); + + const { origin, token } = readSidecar(home); + + // Plant a require_approval policy so the gated tool elicits (bearer-authed — + // the test process is not the app, so it carries the token explicitly). + const policyRes = await fetch(`${origin}/api/policies`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + owner: "org", + pattern: APPROVAL_TARGET_TOOL, + action: "require_approval", + }), + }); + expect(policyRes.ok, `policy create ok (${policyRes.status})`).toBe(true); + + const mcp = new Client({ name: "e2e-desktop-approve", version: "1.0.0" }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport( + new URL(`${origin}/mcp?elicitation_mode=browser`), + { requestInit: { headers: { authorization: `Bearer ${token}` } } }, + ); + await mcp.connect(transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the test owns the MCP transport lifecycle + try { + const executed = await mcp.callTool({ name: "execute", arguments: { code: EXECUTE_CODE } }); + const paused = executed.structuredContent as { + status: string; + executionId: string; + approvalUrl: string; + }; + expect(paused.status, "execute paused for browser approval").toBe("user_approval_required"); + + await step("approve the gated tool in the desktop renderer", async () => { + // The renderer carries no token; the main process injects the bearer for + // THIS window. No ?_token (bootstrap no-ops on desktop). + await page.goto(paused.approvalUrl, { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Approve" }).waitFor({ timeout: 30_000 }); + expect( + await page.getByText("This paused execution is no longer available").count(), + "approval page loaded the paused execution (session-injected bearer reached the gated GET)", + ).toBe(0); + await page.getByRole("button", { name: "Approve" }).click(); + await page.getByText("Approve sent").waitFor({ timeout: 15_000 }); + }); + + const resumed = await mcp.callTool({ + name: "resume", + arguments: { executionId: paused.executionId }, + }); + expect( + (resumed.structuredContent as { status: string }).status, + "resume completed after the in-app approval", + ).toBe("completed"); + } finally { + await mcp.close(); + } + } finally { + await closeWithVideo(app, runDir, home); + } +}; + +// --------------------------------------------------------------------------- +// B. Bearer scoping: a non-app webContents does NOT get the bearer +// --------------------------------------------------------------------------- + +scenario( + "Desktop · the bearer is scoped to the app window — a popup webContents gets 401", + { timeout: 300_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => runBearerScoping(runDir)); + }), +); + +const runBearerScoping = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-desktop-scope-")); + const app = await launchDesktop(home, runDir); + try { + const page = await app.firstWindow({ timeout: 120_000 }); + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + + const { origin } = readSidecar(home); + const gatedUrl = `${origin}/api/scope`; + + // The app's OWN window: the main process injects the bearer → not 401. + const mainStatus = await page.evaluate( + (url) => + fetch(url) + .then((r) => r.status) + .catch(() => -1), + gatedUrl, + ); + expect(mainStatus, "app window: injected bearer reaches the gated endpoint").not.toBe(401); + + // A non-app BrowserWindow (what an OAuth popup is) in the SAME session: its + // requests to the sidecar must NOT get the bearer auto-attached → 401. + const popupStatus = await app.evaluate( + async ({ BrowserWindow }, { origin: o, gatedUrl: g }) => { + const popup = new BrowserWindow({ + show: false, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + // Load the SPA (served unauthenticated) so the fetch has the right origin. + await popup.loadURL(`${o}/`); + const status = await popup.webContents.executeJavaScript( + `fetch(${JSON.stringify(g)}).then((r) => r.status).catch(() => -1)`, + ); + popup.destroy(); + return status as number; + }, + { origin, gatedUrl }, + ); + expect(popupStatus, "popup webContents: no bearer injected → gated endpoint rejects it").toBe( + 401, + ); + + await page.screenshot({ path: join(runDir, "01-bearer-scoping.png") }); + } finally { + await closeWithVideo(app, runDir, home); + } +}; diff --git a/e2e/local/auth.test.ts b/e2e/local/auth.test.ts new file mode 100644 index 000000000..a76ce2b8a --- /dev/null +++ b/e2e/local/auth.test.ts @@ -0,0 +1,76 @@ +// Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way +// a human tests it: run the dev CLI in a real terminal, watch `executor web` +// print its one-time `?_token=` URL, then drive a browser against it. Two clean +// stories, each its own film (terminal.cast + session.mp4 spliced by +// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`): +// +// 1. The CLI's ?_token URL boots straight into an authenticated console. +// 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the +// token connects. +// +// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal +// and hands the printed URL to a body; the terminal stays up until the body is +// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Cli, RunDir, Target } from "../src/services"; +import { withLocalServer } from "./local-server"; + +scenario( + "Local auth · the CLI's ?_token URL boots an authenticated console", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + yield* withLocalServer(cli, runDir, ({ url, token }) => + browser.session(identity, async ({ page, step }) => { + await step("Open the ?_token URL printed by executor web", async () => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); + // Integrations actually LOAD (the built-in Executor source) — proves + // auth + data, not just the static shell. + await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); + // The token is moved out of the URL and persisted to localStorage. + expect(new URL(page.url()).searchParams.has("_token")).toBe(false); + const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); + expect(stored).toBe(token); + }); + }), + ); + }), +); + +scenario( + "Local auth · opening the app without the token shows the gate; pasting it connects", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + yield* withLocalServer(cli, runDir, ({ origin, token }) => + browser.session(identity, async ({ page, step }) => { + await step("Open the app with no token — the login gate appears", async () => { + await page.goto(`${origin}/`, { waitUntil: "domcontentloaded" }); + await page.getByText("Authentication required").waitFor({ timeout: 30_000 }); + }); + + await step("Paste the token (the one in auth.json) and connect", async () => { + await page.getByPlaceholder("Bearer token").fill(token); + await page.getByRole("button", { name: "Connect" }).click(); + await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); + // The reconnect fully restores — integrations LOAD, not a stale 401. + await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); + }); + }), + ); + }), +); diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts new file mode 100644 index 000000000..785d65d83 --- /dev/null +++ b/e2e/local/local-server.ts @@ -0,0 +1,96 @@ +// Shared helper for the `local` e2e project: boot a real `executor web` in a +// recorded terminal, parse its printed one-time `?_token=` URL, and run a body +// against it. Each scenario boots its OWN server (own throwaway data dir, +// `--port 0`) so files can run in parallel without colliding. The terminal +// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so +// the vite child dies and the PTY closes). +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Effect } from "effect"; + +import type { CliSurface } from "../src/surfaces/cli"; +import { markFocus, markRecordingStart } from "../src/timeline"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +/** The `Open: …/?_token=` URL the CLI prints once the server is up. */ +export const TOKEN_URL = /http:\/\/127\.0\.0\.1:\d+\/\?_token=[A-Za-z0-9_-]+/; + +export interface ServerHandle { + /** The full `?_token=` bootstrap URL (origin + token). */ + readonly url: string; + /** The server origin, e.g. `http://127.0.0.1:54321`. */ + readonly origin: string; + /** The bearer token (the `_token` query param), in plaintext. */ + readonly token: string; +} + +/** + * Boot `executor web` and run `body` against the resulting {@link ServerHandle}. + * Keeps the server up until the body settles, then Ctrl-C for a graceful + * shutdown. Cleans up the throwaway data dir. The body may drive the browser, + * a typed API client, an MCP client — anything that needs the live server. + */ +export const withLocalServer = ( + cli: CliSurface, + runDir: string, + body: (server: ServerHandle) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const dataDir = mkdtempSync(join(tmpdir(), "executor-local-e2e-")); + + let publishUrl!: (url: string) => void; + const urlReady = new Promise((res) => { + publishUrl = res; + }); + let signalBodyDone!: () => void; + const bodyDone = new Promise((res) => { + signalBodyDone = res; + }); + + yield* Effect.all( + [ + cli.session( + ["bun", "run", "dev:cli", "web", "--port", "0"], + async (term) => { + markRecordingStart(runDir, "terminal"); + markFocus(runDir, "terminal"); + const snapshot = await term.screen.waitUntil( + (current) => TOKEN_URL.test(current.text), + { timeoutMs: 120_000 }, + ); + const url = TOKEN_URL.exec(snapshot.text)?.[0]; + if (!url) { + throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`); + } + publishUrl(url); + await bodyDone; + // Graceful shutdown so the vite child is killed and the PTY closes; + // otherwise the orphaned child wedges the terminal teardown. + markFocus(runDir, "terminal"); + await term.keyboard.press("Control+C"); + }, + { + cwd: repoRoot, + env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir }, + record: join(runDir, "terminal.cast"), + viewport: { cols: 120, rows: 40 }, + }, + ), + + Effect.gen(function* () { + const url = yield* Effect.promise(() => urlReady); + const parsed = new URL(url); + yield* body({ + url, + origin: parsed.origin, + token: parsed.searchParams.get("_token")!, + }).pipe(Effect.ensuring(Effect.sync(() => signalBodyDone()))); + }), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true })))); + }); diff --git a/e2e/local/mcp-browser-approve.test.ts b/e2e/local/mcp-browser-approve.test.ts new file mode 100644 index 000000000..172d8b809 --- /dev/null +++ b/e2e/local/mcp-browser-approve.test.ts @@ -0,0 +1,146 @@ +// Local-only — the MCP BROWSER-APPROVAL flow, the gap a code review found: +// `resume.$executionId.tsx` POSTed to the bearer-gated `/api/mcp-sessions/*` +// with no Authorization header, so standalone-web approvals 401'd. The existing +// approval scenario (selfhost/mcp-approve.test.ts) approves PROGRAMMATICALLY via +// the MCP `resume` tool (auth on the API path), so it never drives the browser +// page and could not catch this. This drives the real page in a real browser. +// +// Flow: boot `executor web` → create a require_approval policy on a built-in +// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser +// → the server returns a paused `approvalUrl` → open it in the browser (with the +// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a +// negative: the approval endpoint 401s without the bearer. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Browser, Cli, RunDir, Target } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const coreApi = composePluginApi([] as const); + +// A built-in, read-only tool to gate (same target the selfhost approval test +// uses) — calling it under a require_approval policy forces the elicitation. +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +scenario( + "Local · MCP browser approval: a gated execution resumes after the human approves in the browser", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + // Bearer-authed typed API client (local has no session cookie — the + // credential is the printed token). Used only to plant the policy. + const api = yield* HttpApiClient.make(coreApi, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + yield* api.policies.create({ + payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + // MCP client over the wire with the bearer (local /mcp is bearer-gated, + // not OAuth — so the raw SDK transport with an Authorization header, + // not mcporter's PKCE flow). elicitation_mode=browser makes the server + // mint an approval URL instead of a model-side pause. + const mcp = new Client( + { name: "e2e-local-approve", version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport( + new URL(`${server.origin}/mcp?elicitation_mode=browser`), + { requestInit: { headers: { authorization: `Bearer ${server.token}` } } }, + ); + await mcp.connect(transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the test owns the MCP transport lifecycle + try { + const executed = await mcp.callTool({ + name: "execute", + arguments: { code: EXECUTE_CODE }, + }); + const paused = executed.structuredContent as { + status: string; + executionId: string; + approvalUrl: string; + }; + expect(paused.status, "execute paused for browser approval").toBe( + "user_approval_required", + ); + expect(typeof paused.approvalUrl).toBe("string"); + + // The real human flow: approve in the browser FIRST. The page POSTs + // the decision to the bearer-gated /api/mcp-sessions/* endpoint (the + // path bug #2 left unauthenticated) — that just records the decision. + // Calling the MCP `resume` tool first would un-pause the engine and + // the page's getPaused would find nothing, so order matters. + await step("Open the approval URL and approve in the browser", async () => { + const approval = new URL(paused.approvalUrl); + approval.searchParams.set("_token", server.token); // bootstrap the bearer + await page.goto(approval.toString(), { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Approve" }).waitFor({ timeout: 30_000 }); + // The page loaded the paused execution (bearer-authed) — not the + // "unavailable" error branch a getPaused 401/404 would render. + // (Playwright's toBeVisible matcher isn't in vitest's expect.) + expect( + await page.getByText("This paused execution is no longer available").count(), + "approval page loaded the paused execution, not the unavailable branch", + ).toBe(0); + await page.getByRole("button", { name: "Approve" }).click(); + // "Approve sent" only renders if the POST returned 200 — i.e. the + // bearer reached the gated endpoint. Pre-fix it 401'd and stuck. + await page.getByText("Approve sent").waitFor({ timeout: 15_000 }); + }); + + // The agent's `resume` now picks up the recorded approval and the + // engine finishes. + const resumed = await mcp.callTool({ + name: "resume", + arguments: { executionId: paused.executionId }, + }); + const resumedStructured = resumed.structuredContent as { status: string }; + expect( + resumedStructured.status, + "the MCP resume completed once the browser approved (bearer reached the gated endpoint)", + ).toBe("completed"); + + await step("The approval endpoint rejects a request with no bearer", async () => { + const unauthed = await fetch( + `${server.origin}/api/mcp-sessions/${encodeURIComponent( + paused.executionId, + )}/executions/${encodeURIComponent(paused.executionId)}/resume?approval_token=x`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "accept" }), + }, + ); + expect(unauthed.status, "no bearer → 401 at the shell gate").toBe(401); + }); + } finally { + await mcp.close(); + } + }); + }), + ); + }), +); diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index c3572d71b..2cc24903e 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -44,6 +44,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const videoTmp = join(dir, ".video-tmp"); mkdirSync(videoTmp, { recursive: true }); + // Watchable mode (E2E_FILM / E2E_DESK): slow each Playwright action so + // the recording is readable instead of flickering through states at + // machine speed. Off by default — normal CI runs stay fast. + const watchable = process.env.E2E_FILM === "1" || process.env.E2E_DESK === "1"; + const slowMo = watchable ? 400 : undefined; // On the desk (E2E_DESK), the browser is a real headed window on the // virtual display — the desk's single screen recording films it next // to the chat terminal, exactly like a developer tabbing over. @@ -52,8 +57,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface ? { headless: false, args: ["--window-position=300,40", "--window-size=1100,830"], + slowMo, } - : {}, + : slowMo + ? { slowMo } + : {}, ); const context = await browser.newContext({ colorScheme: "dark", diff --git a/e2e/targets/local.ts b/e2e/targets/local.ts new file mode 100644 index 000000000..dd29b7d51 --- /dev/null +++ b/e2e/targets/local.ts @@ -0,0 +1,25 @@ +// The local app as a target. Unlike cloud/self-host, local has no shared +// instance: it is single-user, so each scenario launches its OWN `executor web` +// via the CLI (the Cli surface, recorded as a terminal cast) on its own +// throwaway data dir and an OS-assigned port (`--port 0`). That makes local +// scenarios independent (parallel-safe) and is the CLI+browser flow itself — +// see local/auth.test.ts. So this target carries no baseUrl/token of its own; +// scenarios read the printed `?_token=` URL at runtime and drive the browser +// against it with absolute URLs. +import { Effect } from "effect"; + +import type { Identity, Target } from "../src/target"; + +export const localTarget = (): Target => ({ + name: "local", + // Placeholders: scenarios navigate to absolute URLs (the CLI prints the real + // port), so the browser context's baseURL is never used to resolve a path. + baseUrl: "http://127.0.0.1", + mcpUrl: "http://127.0.0.1/mcp", + // "browser" provides the Browser surface; Cli is always available. No + // "mcp-oauth" (bearer-gated, not OAuth consent) and no "billing". + capabilities: new Set(["browser"]), + // Single-user: a trivial identity with no cookies. The browser authenticates + // via the ?_token bootstrap the scenario performs, not via injected identity. + newIdentity: () => Effect.sync((): Identity => ({ label: "local" })), +}); diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts index 2ad0d4573..c1449bec2 100644 --- a/e2e/targets/registry.ts +++ b/e2e/targets/registry.ts @@ -5,6 +5,7 @@ import type { Target } from "../src/target"; import { cloudTarget } from "./cloud"; import { cloudflareTarget } from "./cloudflare"; import { desktopTarget } from "./desktop"; +import { localTarget } from "./local"; import { selfhostTarget } from "./selfhost"; import { selfhostDockerTarget } from "./selfhost-docker"; @@ -14,6 +15,7 @@ const factories: Record Target> = { "selfhost-docker": selfhostDockerTarget, cloudflare: cloudflareTarget, desktop: desktopTarget, + local: localTarget, }; let current: Target | undefined; diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index c7b8c6358..3c3d98a55 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -56,6 +56,18 @@ export default defineConfig({ fileParallelism: false, testTimeout: 300_000, }), + // The single-user local app. Each scenario launches its OWN `executor + // web` via the CLI on a throwaway data dir + an OS-assigned port, so + // there is no shared instance and scenarios are independent — file + // parallelism is ON. No globalSetup (nothing shared to boot). Only + // local/** scenarios. Not part of the default `npm run test` chain; run + // with `vitest run --project local`. + project("local", { + include: ["local/**/*.test.ts"], + globalSetup: [], + fileParallelism: true, + testTimeout: 180_000, + }), ], }, }); diff --git a/packages/app/src/entry-client.tsx b/packages/app/src/entry-client.tsx index 437785c65..1ddddc521 100644 --- a/packages/app/src/entry-client.tsx +++ b/packages/app/src/entry-client.tsx @@ -1,12 +1,18 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider } from "@tanstack/react-router"; +import { bootstrapLocalAuthToken } from "@executor-js/react/api/local-auth"; import { getRouter } from "./router"; import { initDesktopCrashReporting } from "./crash-reporting"; import "@executor-js/react/globals.css"; initDesktopCrashReporting(); +// Resolve the local bearer token (?_token → localStorage → dev global) and set +// the connection's auth BEFORE the router mounts, so the first API atom carries +// it. No-op on desktop (the main process injects the header). +bootstrapLocalAuthToken(); + const router = getRouter(); ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index 8e50c269f..4d87dcf04 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { createRootRoute } from "@tanstack/react-router"; import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { LocalAuthGate } from "@executor-js/react/api/local-auth"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; @@ -13,7 +14,9 @@ function RootComponent() { return ( - + + + diff --git a/packages/plugins/desktop-settings/src/client.tsx b/packages/plugins/desktop-settings/src/client.tsx index 05607f145..a377d6e40 100644 --- a/packages/plugins/desktop-settings/src/client.tsx +++ b/packages/plugins/desktop-settings/src/client.tsx @@ -26,8 +26,6 @@ import { defineClientPlugin } from "@executor-js/sdk/client"; interface DesktopServerSettings { readonly port: number; - readonly requireAuth: boolean; - readonly password: string; } interface DesktopServerConnection { @@ -36,20 +34,17 @@ interface DesktopServerConnection { readonly origin: string; readonly apiBaseUrl: string; readonly displayName: string; - readonly auth?: { - readonly kind: "basic"; - readonly username: string; - readonly password: string; - }; } interface ExecutorBridge { readonly getServerConnection: () => Promise; + // The bearer token, fetched on demand to display the CLI/MCP connect command. + readonly getServerAuthToken?: () => Promise; readonly getSettings: () => Promise; readonly updateSettings: ( patch: Partial, ) => Promise; - readonly regeneratePassword: () => Promise; + readonly rotateToken: () => Promise; readonly restartServer: () => Promise; // Optional: present in desktop builds that ship the diagnostics export. readonly exportDiagnostics?: () => Promise; @@ -86,18 +81,22 @@ function SettingsPage() { const [connection, setConnection] = useState(null); const [settings, setSettings] = useState(null); const [draft, setDraft] = useState(null); + const [authToken, setAuthToken] = useState(null); const [status, setStatus] = useState<"idle" | "saving" | "restarting" | "error">("idle"); const [error, setError] = useState(null); useEffect(() => { if (!bridge) return; - void Promise.all([bridge.getSettings(), bridge.getServerConnection()]).then( - ([nextSettings, nextConnection]) => { - setSettings(nextSettings); - setDraft(nextSettings); - setConnection(nextConnection); - }, - ); + void Promise.all([ + bridge.getSettings(), + bridge.getServerConnection(), + bridge.getServerAuthToken?.() ?? Promise.resolve(null), + ]).then(([nextSettings, nextConnection, nextToken]) => { + setSettings(nextSettings); + setDraft(nextSettings); + setConnection(nextConnection); + setAuthToken(nextToken); + }); }, [bridge]); const restartAndRefreshConnection = useCallback(async () => { @@ -152,22 +151,20 @@ function SettingsPage() { } }, [bridge]); - const regenerate = useCallback(async () => { + const rotate = useCallback(async () => { if (!bridge) return; - setStatus("saving"); - let next: DesktopServerSettings; + setStatus("restarting"); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC try { - next = await bridge.regeneratePassword(); + setConnection(await bridge.rotateToken()); + setAuthToken((await bridge.getServerAuthToken?.()) ?? null); } catch (err) { setError(describeIpcError(err)); setStatus("error"); return; } - setSettings(next); - setDraft(next); - await restartAndRefreshConnection(); - }, [bridge, restartAndRefreshConnection]); + setStatus("idle"); + }, [bridge]); if (!bridge) { return ( @@ -184,20 +181,13 @@ function SettingsPage() { return
Loading…
; } - const dirty = - draft.port !== settings.port || - draft.requireAuth !== settings.requireAuth || - draft.password !== settings.password; + const dirty = draft.port !== settings.port; - const authLabel = - connection.auth?.kind === "basic" - ? `Basic auth as ${connection.auth.username}` - : "No HTTP auth"; + const authLabel = "Bearer token"; const cliProfileCommand = `executor server add desktop ${connection.origin} --default`; - const cliUseCommand = - connection.auth?.kind === "basic" - ? `EXECUTOR_AUTH_PASSWORD=${connection.auth.password} executor tools sources --server desktop` - : "executor tools sources --server desktop"; + const cliUseCommand = authToken + ? `EXECUTOR_AUTH_TOKEN=${authToken} executor tools sources --server desktop` + : "executor tools sources --server desktop"; return (
@@ -273,77 +263,54 @@ function SettingsPage() { - - - {draft.requireAuth && ( -
- Password -
- - {settings.password} - - {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} - -
- - Regenerating this changes the active server connection and invalidates existing HTTP - MCP client configs. - +
+ Bearer token +
+ + {authToken ?? "—"} + + {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} +
- )} + + The sidecar enforces this token on /api and /mcp. Rotating it + restarts the connection and invalidates existing MCP client configs — re-run your + connect command afterwards. + +
{/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} + +
+ ); +} diff --git a/packages/react/src/api/oauth-popup.ts b/packages/react/src/api/oauth-popup.ts index 8f0e0ea47..3c9bb7834 100644 --- a/packages/react/src/api/oauth-popup.ts +++ b/packages/react/src/api/oauth-popup.ts @@ -12,6 +12,7 @@ import { isOAuthPopupResult as sharedIsOAuthPopupResult, type OAuthPopupResult, } from "@executor-js/sdk/shared"; +import { getExecutorServerAuthorizationHeader } from "./server-connection"; export { OAUTH_POPUP_MESSAGE_TYPE } from "@executor-js/sdk/shared"; export type { OAuthPopupResult } from "@executor-js/sdk/shared"; @@ -267,9 +268,14 @@ export const openOAuthSystemBrowser = ( if (settled) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling try { + // The await poll is now gated like the rest of /api — carry the bearer + // (standalone web). On desktop the connection has no client-side auth and + // the main process injects the header instead. + const authorization = getExecutorServerAuthorizationHeader(); const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, { signal: controller.signal, cache: "no-store", + ...(authorization ? { headers: { authorization } } : {}), }); if (!response.ok) return; const body = (await response.json()) as unknown; diff --git a/packages/react/src/api/server-connection.test.ts b/packages/react/src/api/server-connection.test.ts index c240a23a0..2a7062829 100644 --- a/packages/react/src/api/server-connection.test.ts +++ b/packages/react/src/api/server-connection.test.ts @@ -34,18 +34,33 @@ describe("Executor server connection", () => { }); }); - it("preserves desktop sidecar compatibility from the legacy window bridge", () => { + it("uses the bridge-provided connection when present", () => { const connection = resolveBrowserExecutorServerConnection({ locationOrigin: "https://ignored.example", bridge: { - baseUrl: "http://127.0.0.1:4789", - authPassword: "secret", + serverConnection: { + kind: "desktop-sidecar", + origin: "http://127.0.0.1:4789", + displayName: "Desktop sidecar", + }, }, }); expect(connection.kind).toBe("desktop-sidecar"); expect(connection.origin).toBe("http://127.0.0.1:4789"); expect(connection.apiBaseUrl).toBe("http://127.0.0.1:4789/api"); - expect(getExecutorServerAuthorizationHeader(connection)).toBe("Basic ZXhlY3V0b3I6c2VjcmV0"); + // The renderer connection carries no auth — the desktop main process injects + // the bearer header at the session layer. + expect(getExecutorServerAuthorizationHeader(connection)).toBeNull(); + }); + + it("falls back to the location origin with no auth when no bridge is present", () => { + const connection = resolveBrowserExecutorServerConnection({ + locationOrigin: "http://localhost:4788", + }); + + expect(connection.kind).toBe("http"); + expect(connection.origin).toBe("http://localhost:4788"); + expect(getExecutorServerAuthorizationHeader(connection)).toBeNull(); }); }); diff --git a/packages/react/src/api/server-connection.tsx b/packages/react/src/api/server-connection.tsx index 1f56125fa..db4a92dde 100644 --- a/packages/react/src/api/server-connection.tsx +++ b/packages/react/src/api/server-connection.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { isValidOrgSlug } from "@executor-js/api"; import { DEFAULT_EXECUTOR_SERVER_ORIGIN, - DEFAULT_EXECUTOR_SERVER_USERNAME, getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection, normalizeExecutorServerConnection, originFromApiBaseUrl, @@ -12,7 +11,6 @@ import { export { DEFAULT_EXECUTOR_SERVER_ORIGIN, - DEFAULT_EXECUTOR_SERVER_USERNAME, apiBaseUrlForServerOrigin, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, @@ -26,10 +24,15 @@ export { interface ExecutorWindowBridge { readonly serverConnection?: ExecutorServerConnectionInput; readonly getServerConnection?: () => Promise; + /** + * The desktop bearer token, fetched on demand for the "Connect an agent" + * install command (an external agent needs it in plaintext). The renderer's + * own requests don't use it — the desktop main process injects the header at + * the session layer. + */ + readonly getServerAuthToken?: () => Promise; readonly getServerProfiles?: () => Promise; readonly setServerProfiles?: (value: string) => Promise; - readonly baseUrl?: string; - readonly authPassword?: string; } declare global { @@ -47,24 +50,6 @@ export const resolveBrowserExecutorServerConnection = (input: { return normalizeExecutorServerConnection(configured); } - const legacyBaseUrl = input.bridge?.baseUrl; - if (legacyBaseUrl) { - return normalizeExecutorServerConnection({ - kind: "desktop-sidecar", - origin: legacyBaseUrl, - displayName: "Desktop sidecar", - ...(input.bridge?.authPassword - ? { - auth: { - kind: "basic", - username: DEFAULT_EXECUTOR_SERVER_USERNAME, - password: input.bridge.authPassword, - }, - } - : {}), - }); - } - return normalizeExecutorServerConnection({ kind: "http", origin: input.locationOrigin ?? DEFAULT_EXECUTOR_SERVER_ORIGIN, diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index 4dc69b18e..df31a2094 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -36,10 +36,10 @@ describe("MCP install command rendering", () => { mode: "http", isDev: false, origin: "http://127.0.0.1:4789", - authorizationHeader: "Basic abc123", + authorizationHeader: "Bearer abc123", }), ).toBe( - "npx add-mcp http://127.0.0.1:4789/mcp --transport http --name executor --header 'Authorization: Basic abc123'", + "npx add-mcp http://127.0.0.1:4789/mcp --transport http --name executor --header 'Authorization: Bearer abc123'", ); }); diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index df4b7b676..4ee60a41b 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { trackEvent } from "../api/analytics"; import CursorIcon from "@lobehub/icons/es/Cursor/components/Mono"; import ClaudeIcon from "@lobehub/icons/es/Claude/components/Color"; @@ -70,15 +70,6 @@ export const buildMcpHttpEndpoint = (input: { return url.toString(); }; -const buildBasicAuthHeader = (password: string): string => { - // Renderer-only: every browser/Electron renderer has btoa. SSR doesn't - // render this card, so we don't need a Node fallback here. - if (typeof globalThis.btoa !== "function") { - return `Authorization: Basic executor:${password}`; - } - return `Authorization: Basic ${globalThis.btoa(`executor:${password}`)}`; -}; - export const buildMcpInstallCommand = (input: { readonly mode: TransportMode; readonly isDev: boolean; @@ -86,8 +77,6 @@ export const buildMcpInstallCommand = (input: { readonly scopeDir?: string; readonly desktop?: { readonly port: number; - readonly requireAuth: boolean; - readonly password: string; } | null; readonly authorizationHeader?: string | null; readonly elicitationMode?: McpElicitationMode; @@ -104,8 +93,6 @@ export const buildMcpInstallCommand = (input: { const headerFlags: string[] = []; if (input.authorizationHeader) { headerFlags.push(`--header ${shellQuoteWord(`Authorization: ${input.authorizationHeader}`)}`); - } else if (input.desktop?.requireAuth && input.desktop.password) { - headerFlags.push(`--header ${shellQuoteWord(buildBasicAuthHeader(input.desktop.password))}`); } const parts = [ `npx add-mcp ${shellQuoteWord(endpoint)} --transport http --name executor`, @@ -141,7 +128,32 @@ export function McpInstallCard(props: { className?: string }) { isLocal && serverConnection.kind !== "desktop-sidecar" && !hasDesktopConnectionBridge(); const elicitationMode = mode === "stdio" ? "model" : httpElicitationMode; - const authorizationHeader = getExecutorServerAuthorizationHeader(serverConnection); + + // The desktop renderer's connection carries no auth (the main process injects + // the bearer at the session layer). For the install command — which an + // EXTERNAL agent runs and therefore needs the token in plaintext — fetch it + // on demand from the bridge. + const [desktopAuthToken, setDesktopAuthToken] = useState(null); + useEffect(() => { + if (serverConnection.kind !== "desktop-sidecar") { + setDesktopAuthToken(null); + return; + } + let cancelled = false; + void globalThis.window?.executor?.getServerAuthToken?.().then( + (token) => { + if (!cancelled) setDesktopAuthToken(token); + }, + () => undefined, + ); + return () => { + cancelled = true; + }; + }, [serverConnection.kind]); + + const authorizationHeader = + getExecutorServerAuthorizationHeader(serverConnection) ?? + (desktopAuthToken ? `Bearer ${desktopAuthToken}` : null); const command = buildMcpInstallCommand({ mode, diff --git a/packages/react/src/routes/resume.$executionId.tsx b/packages/react/src/routes/resume.$executionId.tsx index 8ce0878e4..ea1430240 100644 --- a/packages/react/src/routes/resume.$executionId.tsx +++ b/packages/react/src/routes/resume.$executionId.tsx @@ -5,6 +5,7 @@ import * as Atom from "effect/unstable/reactivity/Atom"; import { createFileRoute } from "@tanstack/react-router"; import { ResumeApprovalPage, ResumeApprovalPageView } from "../pages/resume-approval"; +import { getExecutorServerAuthorizationHeader } from "../api/server-connection"; import type { ElicitationAction } from "../components/elicitation-approval"; const SearchParams = Schema.toStandardSchemaV1( @@ -45,10 +46,16 @@ const mcpPausedExecutionAtom = Atom.family( (key: { readonly mcpSessionId: string; readonly executionId: string }) => Atom.make( Effect.gen(function* () { + // `/api/mcp-sessions/*` is bearer-gated. Attach the bearer (standalone + // web reads it from localStorage; desktop injects it at the session + // layer, so this is null and we send none) — otherwise this 401s on the + // single-user local server and the page shows "unavailable". + const authorization = getExecutorServerAuthorizationHeader(); const response = yield* Effect.tryPromise({ try: () => fetch( `/api/mcp-sessions/${encodeURIComponent(key.mcpSessionId)}/executions/${encodeURIComponent(key.executionId)}`, + authorization ? { headers: { authorization } } : undefined, ), catch: () => new LocalMcpResumeError({ message: "Failed to load the paused execution." }), }); @@ -81,13 +88,21 @@ type LocalMcpResumeInput = { const resumeLocalMcpExecution = Atom.fn()((input) => Effect.gen(function* () { + // `/api/mcp-sessions/*` is bearer-gated like the rest of /api. Attach the + // bearer the same way the typed client does (standalone web reads it from + // localStorage; on desktop the connection carries no auth and the main + // process injects the header, so this is null and we send none). + const authorization = getExecutorServerAuthorizationHeader(); const response = yield* Effect.tryPromise({ try: () => fetch( `/api/mcp-sessions/${encodeURIComponent(input.mcpSessionId)}/executions/${encodeURIComponent(input.executionId)}/resume`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + ...(authorization ? { authorization } : {}), + }, body: JSON.stringify( input.action === "accept" ? { action: input.action, content: input.content ?? {} } @@ -143,7 +158,12 @@ function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: stri const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); const resume = useCallback( (executionId: string, action: ElicitationAction, content?: Record) => - doResume({ mcpSessionId: props.mcpSessionId, executionId, action, content }), + doResume({ + mcpSessionId: props.mcpSessionId, + executionId, + action, + content, + }), [doResume, props.mcpSessionId], );