diff --git a/.env.example b/.env.example index 1e55b48..09bcfbc 100644 --- a/.env.example +++ b/.env.example @@ -13,13 +13,17 @@ NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev # API Configuration - Only needed to set the Kernel SDK base url API_BASE_URL= +# Public origin of this MCP server. The Managed Auth MCP App connects only to +# the narrowly scoped same-origin relay. Local development: http://localhost:3002 +MANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com + # Mintlify API Configuration - Only needed for the search_docs tool call MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_ MINTLIFY_DOMAIN= # Optional MCP toolset gating. Comma-separated values. # Example: api_keys hides manage_api_keys so deployments can opt out of key management. -# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell +# Supported: apps, api_keys, auth_connections, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell # KERNEL_MCP_DISABLED_TOOLSETS=api_keys # Redis Configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6b331d..acfcb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,20 @@ jobs: steps: - uses: actions/checkout@v4 + # Pin Bun: the managed-auth App bundle check is byte-exact and Bun's + # minifier output can change between releases. Regenerate the bundle + # with this exact version when bumping it. - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.3" - run: bun install --frozen-lockfile + - name: Check managed-auth App bundle + run: bun run check:managed-auth-app + - name: Type check - run: bunx tsc --noEmit + run: bunx tsc --noEmit --incremental false + + - name: Test + run: bun test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..231db60 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/lib/mcp/apps/generated/managed-auth-app.ts diff --git a/README.md b/README.md index 6529328..5cbca5c 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,11 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (16 total) +## Tools (17 model-facing, plus 1 app-only helper) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Five standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows. + +One additional Managed Auth helper (`begin_auth_login`) is marked app-only (`_meta.ui.visibility: ["app"]`); it refuses to execute on hosts that do not declare MCP Apps support. The App itself polls the shared, baseline-guarded `manage_auth_connections` `wait` action for status. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. @@ -272,7 +274,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan. - `manage_extensions` - List and delete uploaded browser extensions. - `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. -- `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections. +- `manage_auth_connections` - Create, list, get, delete, login, submit, and wait for managed-auth connections in every client. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. @@ -283,6 +285,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. +- `open_auth_login` - Open a secure interactive Managed Auth MCP App after user consent. Registered only for clients that declare MCP Apps support; credentials and MFA never enter MCP/model traffic. ## Resources @@ -327,6 +330,19 @@ Assistant: I'll create a browser session, then execute Playwright code against i Returns: { success: true, result: "Example Domain" } ``` +### Use managed authentication for a protected site + +1. Call `manage_auth_connections` with `action: "list"` and the exact `domain_filter`. +2. Fetch all pages. Reuse an authenticated connection; ask only when multiple relevant accounts match. +3. A direct request to log in is consent. If authentication is discovered incidentally, ask before opening the App. +4. For a new connection, choose a concise service-derived profile name unless the user supplied one; do not ask solely for a profile name. +5. Call `open_auth_login`, then immediately follow its `next_action` and repeat the read-only wait while it reports `pending`. +6. The user enters credentials/MFA only in the secure App. Once the wait reports `authenticated`, resume the original task with the verified `profile_name`. + +Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. + +The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. The programmatic `manage_auth_connections` create/login actions preserve the API’s opt-in and inheritance behavior when these parameters are omitted. + ### Set up browser profiles for authentication ``` diff --git a/bun.lock b/bun.lock index 98bc3f1..1373d54 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,8 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.78.0", + "@onkernel/managed-auth-react": "0.4.1", + "@onkernel/sdk": "^0.85.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -33,6 +34,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", @@ -147,7 +149,9 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.78.0", "", {}, "sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw=="], + "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.4.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-8p+pMljBRQMKLiBFWbyJQ2ohYflsomWYGgQtlF2sbb4b2w/z+CBsnxUiBs1q23h/W1OtHsbw/jKGX51ZCjNYzA=="], + + "@onkernel/sdk": ["@onkernel/sdk@0.85.0", "", {}, "sha512-u5EKb2itzuUV5Yq+AQ1mJ1xsnsXgvNrEUrg40KjVw9GWa+deE5jCsVtBrQaGF8Ysbx//QkG0k0jyAZxgcsfDNA=="], "@posthog/core": ["@posthog/core@1.45.2", "", { "dependencies": { "@posthog/types": "^1.398.0" } }, "sha512-OhEHkojFkqEFbtm/wUtLYgomN1gFNU9IyufvNsuZvpIOh8TZ9tnAvI81Sej/2zu+vyDExs9JroQB5SW3y5QyOw=="], @@ -201,6 +205,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/jszip": ["@types/jszip@3.4.1", "", { "dependencies": { "jszip": "*" } }, "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A=="], @@ -229,6 +235,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -243,6 +251,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], diff --git a/package.json b/package.json index 57f008c..244a48a 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ }, "scripts": { "dev": "next dev -p 3002", - "build": "next build", + "build:managed-auth-app": "bun scripts/build-managed-auth-app.mjs", + "check:managed-auth-app": "bun scripts/build-managed-auth-app.mjs --check", + "build": "bun run build:managed-auth-app && next build", "start": "next start -p 3002", "lint": "next lint", + "test": "bun test", "format": "prettier --write \"**/*.{ts,js,json,md}\"", "format:check": "prettier --check \"**/*.{ts,js,json,md}\"" }, @@ -34,7 +37,8 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.78.0", + "@onkernel/managed-auth-react": "0.4.1", + "@onkernel/sdk": "^0.85.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -57,6 +61,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", diff --git a/scripts/build-managed-auth-app.mjs b/scripts/build-managed-auth-app.mjs new file mode 100644 index 0000000..3d1394b --- /dev/null +++ b/scripts/build-managed-auth-app.mjs @@ -0,0 +1,90 @@ +// Builds the managed-auth MCP App into a single self-contained HTML bundle. +// The --check mode is byte-exact, and Bun's minifier output can change between +// releases, so the bundle is only reproducible with the Bun version pinned in +// .github/workflows/ci.yml (currently 1.3.3). Regenerate the bundle with that +// exact version: bun run build:managed-auth-app +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const entrypoint = join(root, "src/lib/mcp/apps/managed-auth-entry.tsx"); +const generatedPath = join( + root, + "src/lib/mcp/apps/generated/managed-auth-app.ts", +); +const check = process.argv.includes("--check"); +const temp = await mkdtemp(join(tmpdir(), "kernel-managed-auth-app-")); + +try { + const build = await Bun.build({ + entrypoints: [entrypoint], + outdir: temp, + target: "browser", + format: "esm", + minify: true, + splitting: false, + sourcemap: "none", + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + }); + + if (!build.success) { + for (const log of build.logs) console.error(log); + process.exitCode = 1; + } else { + let javascript = ""; + let css = ""; + for (const output of build.outputs) { + if (output.path.endsWith(".js")) javascript += await output.text(); + if (output.path.endsWith(".css")) css += await output.text(); + } + if (!javascript) + throw new Error("Bun did not emit managed-auth JavaScript"); + + const escapeScript = (value) => value.replaceAll(" value.replaceAll(" + + + + +Kernel Managed Authentication + + +
+ + +`; + const generated = `// Generated by scripts/build-managed-auth-app.mjs. Do not edit.\nexport const MANAGED_AUTH_APP_HTML = ${JSON.stringify(html)};\n`; + + if (check) { + let current = ""; + try { + current = await readFile(generatedPath, "utf8"); + } catch { + // Report the same actionable stale-bundle error below. + } + if (current !== generated) { + console.error( + "Managed-auth App bundle is stale. Run: bun run build:managed-auth-app", + ); + process.exitCode = 1; + } + } else { + await mkdir(resolve(generatedPath, ".."), { recursive: true }); + await writeFile(generatedPath, generated); + console.log(`Generated ${generatedPath}`); + } + } +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 78f133c..68c0f52 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -12,6 +12,71 @@ import { mintMcpSessionId, } from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; +import { initializeDeclaresMcpApps } from "@/lib/mcp/tools/auth-login-app"; +import { + clearMcpAppsClient, + hasMcpAppsClient, + markMcpAppsClient, +} from "@/lib/redis"; + +// The streamable-HTTP transport creates one McpServer per request, so the +// initialize capability is unavailable when tools/list or an App call arrives. +// Persist the negotiation marker, then select the additive App registration +// only for requests that need to discover or invoke it. +async function requestUsesMcpApps( + req: NextRequest, + token: string, + ttlSeconds: number, +): Promise { + if (req.method !== "POST") return false; + let body: unknown; + try { + body = await req.clone().json(); + } catch { + return false; + } + + const request = + body && typeof body === "object" && !Array.isArray(body) + ? (body as { + method?: unknown; + params?: { name?: unknown; uri?: unknown }; + }) + : null; + if (request?.method === "initialize") { + const declaresApps = initializeDeclaresMcpApps(body); + try { + if (declaresApps) { + await markMcpAppsClient({ token, ttlSeconds }); + } else { + await clearMcpAppsClient(token); + } + } catch (error) { + // Never block initialize; without a marker, later App discovery and + // invocation fail closed to the base tool set. + console.error("Failed to record MCP Apps capability:", error); + } + return false; + } + + const needsAppRegistration = + request?.method === "tools/list" || + request?.method === "resources/list" || + (request?.method === "resources/read" && + typeof request.params?.uri === "string" && + request.params.uri.startsWith("ui://kernel/managed-auth-login")) || + (request?.method === "tools/call" && + (request.params?.name === "open_auth_login" || + request.params?.name === "begin_auth_login")); + if (!needsAppRegistration) return false; + + try { + return await hasMcpAppsClient({ token, ttlSeconds }); + } catch (error) { + console.error("MCP Apps capability check failed; using base tools:", error); + return false; + } +} export async function OPTIONS(_req: NextRequest): Promise { return new Response(null, { @@ -48,11 +113,16 @@ function createAuthErrorResponse( ); } -// Create MCP handler with tools +// The base tool set is unchanged. Capability negotiation only adds the +// Managed Auth launcher, its resource, and its app-only implementation tools. const handler = createMcpHandler((server) => { instrumentMcpAnalytics(server); registerMcpCapabilities(server); }); +const mcpAppsHandler = createMcpHandler((server) => { + instrumentMcpAnalytics(server); + registerMcpCapabilities(server, { mcpApps: true }); +}); async function handleAuthenticatedRequest(req: NextRequest): Promise { const authHeader = req.headers.get("Authorization"); @@ -67,8 +137,13 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } if (!isValidJwtFormat(token)) { + // Opaque API keys are authenticated by the Kernel API rather than Clerk. + // Select the additive App handler from their token-bound marker here. + const selectedHandler = (await requestUsesMcpApps(req, token, 24 * 60 * 60)) + ? mcpAppsHandler + : handler; const authHandler = withMcpAuth( - handler, + selectedHandler, async () => ({ token, scopes: ["apikey"], @@ -95,9 +170,15 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { ); } + // JWT markers are keyed by the decoded sid, so only read or mutate them + // after Clerk has verified the token and its expiry. + const selectedHandler = (await requestUsesMcpApps(req, token, 24 * 60 * 60)) + ? mcpAppsHandler + : handler; + // Create authenticated handler with auth info const authHandler = withMcpAuth( - handler, + selectedHandler, async (_req, _providedToken) => { // Return auth info with validated user data return { diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts new file mode 100644 index 0000000..a0ea8bf --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { proxyManagedAuthRequest } from "./route"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + btoa(JSON.stringify(value)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +const scopedToken = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 4102444800, +}); + +function request( + path: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {}, +) { + return new Request(`http://localhost:3002${path}`, { + method: options.method ?? "GET", + headers: options.headers, + body: options.body, + }); +} + +function expectCors(response: Response) { + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe( + "GET, POST, OPTIONS", + ); +} + +describe("managed-auth relay", () => { + test("rejects invalid paths, methods, query parameters, and API keys", async () => { + const invalidPath = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/arbitrary"), + ["c_1", "arbitrary"], + ); + expect(invalidPath.status).toBe(404); + expectCors(invalidPath); + + let traversalForwarded = false; + const traversal = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/%2e%2e/exchange", { + method: "POST", + body: "{}", + }), + ["..", "exchange"], + (async () => { + traversalForwarded = true; + return new Response(null); + }) as unknown as typeof fetch, + ); + expect(traversal.status).toBe(404); + expect(traversalForwarded).toBe(false); + + const invalidMethod = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "POST", + }), + ["c_1", "events"], + ); + expect(invalidMethod.status).toBe(405); + expectCors(invalidMethod); + + const query = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1?upstream=evil"), + ["c_1"], + ); + expect(query.status).toBe(400); + + const apiKey = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: "Bearer sk_not_a_managed_auth_jwt" }, + }), + ["c_1"], + ); + expect(apiKey.status).toBe(401); + expectCors(apiKey); + }); + + test("rejects expired scoped JWTs at the relay boundary", async () => { + const expired = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 1700000000, // 2023-11-14, in the past + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: `Bearer ${expired}` }, + }), + ["c_1"], + ); + expect(response.status).toBe(401); + expectCors(response); + }); + + test("allows unauthenticated exchange and strips cookies and arbitrary headers", async () => { + let forwarded: RequestInit | undefined; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwarded = init; + return new Response('{"jwt":"scoped-secret"}', { + headers: { "content-type": "application/json", "set-cookie": "bad=1" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { + cookie: "mcp=secret", + "x-forwarded-for": "127.0.0.1", + "x-arbitrary": "do-not-forward", + "content-type": "application/json", + accept: "application/json", + }, + body: '{"code":"handoff-secret"}', + }), + ["c_1", "exchange"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expectCors(response); + const headers = new Headers(forwarded?.headers); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("x-forwarded-for")).toBeNull(); + expect(headers.get("x-arbitrary")).toBeNull(); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + test("passes scoped JWT only to fixed authenticated endpoints", async () => { + let forwardedAuthorization = ""; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwardedAuthorization = + new Headers(init?.headers).get("authorization") ?? ""; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/submit", { + method: "POST", + headers: { + authorization: `Bearer ${scopedToken}`, + "content-type": "application/json", + }, + body: "{}", + }), + ["c_1", "submit"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expect(forwardedAuthorization).toBe(`Bearer ${scopedToken}`); + }); + + test("preserves unbuffered SSE and CORS preflight", async () => { + const upstream = async () => + new Response("event: status\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + headers: { authorization: `Bearer ${scopedToken}` }, + }), + ["c_1", "events"], + upstream as unknown as typeof fetch, + ); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("cache-control")).toBe( + "no-cache, no-transform", + ); + expect(response.headers.get("x-accel-buffering")).toBe("no"); + expect(response.headers.get("content-encoding")).toBe("identity"); + expectCors(response); + + const preflight = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "OPTIONS", + }), + ["c_1", "events"], + ); + expect(preflight.status).toBe(204); + expectCors(preflight); + }); + + test("enforces the request body limit and does not log secrets", async () => { + const tooLarge = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { "content-length": String(65 * 1024) }, + body: "x", + }), + ["c_1", "exchange"], + ); + expect(tooLarge.status).toBe(413); + expectCors(tooLarge); + + let canceled = false; + const chunkedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { + canceled = true; + }, + }); + const chunked = await proxyManagedAuthRequest( + new Request( + "http://localhost:3002/managed-auth-proxy/auth/connections/c_1/exchange", + { method: "POST", body: chunkedBody }, + ), + ["c_1", "exchange"], + ); + expect(chunked.status).toBe(413); + expect(canceled).toBe(true); + + const calls: unknown[][] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => calls.push(args); + console.error = (...args) => calls.push(args); + try { + await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + body: '{"code":"never-log-this"}', + }), + ["c_1", "exchange"], + (async () => + new Response( + '{"jwt":"never-log-this-either"}', + )) as unknown as typeof fetch, + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + expect(calls).toHaveLength(0); + }); +}); diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts new file mode 100644 index 0000000..20407f3 --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts @@ -0,0 +1,214 @@ +const MAX_BODY_BYTES = 64 * 1024; +const ALLOWED_REQUEST_HEADERS = ["authorization", "content-type", "accept"]; +const ALLOWED_METHODS = "GET, POST, OPTIONS"; + +type RouteContext = { params: Promise<{ path: string[] }> }; +type FetchLike = typeof fetch; + +function corsHeaders(cacheControl = "private, no-store") { + return new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": ALLOWED_METHODS, + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept", + "Cache-Control": cacheControl, + }); +} + +function responseError(status: number, message: string) { + return new Response(message, { status, headers: corsHeaders() }); +} + +function validConnectionId(value: string | undefined): value is string { + // URL resolves literal and percent-encoded dot segments before fetching. + // Reject them explicitly so the connection ID can never escape the fixed + // /auth/connections/ upstream prefix. + return !!value && value !== "." && value !== ".."; +} + +function validOperation(path: string[], method: string): boolean { + if (path.length === 1 && method === "GET") { + return validConnectionId(path[0]); + } + if (path.length !== 2 || !validConnectionId(path[0]) || !path[1]) { + return false; + } + if (method === "POST") { + return path[1] === "exchange" || path[1] === "submit"; + } + return method === "GET" && path[1] === "events"; +} + +function pathExists(path: string[]): boolean { + return ( + (path.length === 1 && validConnectionId(path[0])) || + (path.length === 2 && + validConnectionId(path[0]) && + ["exchange", "submit", "events"].includes(path[1])) + ); +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (normalized.length % 4)) % 4); + return JSON.parse(atob(normalized + padding)) as Record; + } catch { + return null; + } +} + +function managedAuthAuthorization(request: Request): string | null { + const authorization = request.headers.get("authorization"); + const match = authorization?.match(/^Bearer\s+([^\s]+)$/i); + if (!match) return null; + const claims = decodeJwtPayload(match[1]); + if ( + claims?.iss !== "kernel-api" || + typeof claims.managed_auth_session_id !== "string" || + !claims.managed_auth_session_id || + typeof claims.exp !== "number" || + // Reject expired session JWTs at the relay boundary instead of + // forwarding them upstream. + claims.exp * 1000 <= Date.now() + ) { + return null; + } + return authorization; +} + +async function readSmallBody(request: Request): Promise { + const length = request.headers.get("content-length"); + if (length) { + const parsedLength = Number(length); + if ( + !Number.isSafeInteger(parsedLength) || + parsedLength < 0 || + parsedLength > MAX_BODY_BYTES + ) { + await request.body?.cancel(); + return null; + } + } + + if (!request.body) return new ArrayBuffer(0); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body.buffer; +} + +export async function proxyManagedAuthRequest( + request: Request, + path: string[], + fetchUpstream: FetchLike = fetch, +): Promise { + if (new URL(request.url).search) { + return responseError(400, "Query parameters are not allowed"); + } + + if (request.method === "OPTIONS") { + return pathExists(path) + ? new Response(null, { status: 204, headers: corsHeaders() }) + : responseError(404, "Not found"); + } + + if (!pathExists(path)) return responseError(404, "Not found"); + if (!validOperation(path, request.method)) { + return responseError(405, "Method not allowed"); + } + + const isExchange = path.length === 2 && path[1] === "exchange"; + const authorization = isExchange ? null : managedAuthAuthorization(request); + if (!isExchange && !authorization) { + return responseError(401, "Invalid managed-auth authorization"); + } + + let body: ArrayBuffer | undefined; + if (request.method === "POST") { + const requestBody = await readSmallBody(request); + if (requestBody === null) { + return responseError(413, "Request body too large"); + } + body = requestBody; + } + + const upstreamHeaders = new Headers(); + for (const name of ALLOWED_REQUEST_HEADERS) { + if (name === "authorization") continue; + const value = request.headers.get(name); + if (value) upstreamHeaders.set(name, value); + } + if (authorization) upstreamHeaders.set("authorization", authorization); + + const baseUrl = process.env.API_BASE_URL ?? "https://api.onkernel.com"; + const upstreamUrl = new URL( + `/auth/connections/${path.map(encodeURIComponent).join("/")}`, + baseUrl, + ); + const upstream = await fetchUpstream(upstreamUrl, { + method: request.method, + headers: upstreamHeaders, + ...(body && { body }), + redirect: "manual", + }); + + if (upstream.status >= 300 && upstream.status < 400) { + return responseError(502, "Upstream redirect rejected"); + } + + const isEvents = path.length === 2 && path[1] === "events"; + const headers = corsHeaders( + isEvents ? "no-cache, no-transform" : "private, no-store", + ); + const contentType = upstream.headers.get("content-type"); + if (isEvents) { + headers.set("Content-Type", "text/event-stream"); + headers.set("X-Accel-Buffering", "no"); + headers.set("Content-Encoding", "identity"); + } else if (contentType) { + headers.set("Content-Type", contentType); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + +async function handle(request: Request, context: RouteContext) { + const { path } = await context.params; + try { + return await proxyManagedAuthRequest(request, path); + } catch { + return responseError(502, "Managed-auth relay unavailable"); + } +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const OPTIONS = handle; +export const DELETE = handle; +export const PATCH = handle; +export const PUT = handle; diff --git a/src/lib/mcp-apps-marker.test.ts b/src/lib/mcp-apps-marker.test.ts new file mode 100644 index 0000000..0e3782b --- /dev/null +++ b/src/lib/mcp-apps-marker.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { mcpAppsMarkerSubject } from "@/lib/mcp-apps-marker"; + +process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +describe("MCP Apps marker subject", () => { + test("survives OAuth access-token refresh via the session id", () => { + const first = jwt({ sub: "user_1", sid: "sess_1", exp: 100 }); + const refreshed = jwt({ + sub: "user_1", + sid: "sess_1", + exp: 200, + iat: 150, + }); + expect(mcpAppsMarkerSubject(first)).toBe("sid:sess_1"); + expect(mcpAppsMarkerSubject(refreshed)).toBe("sid:sess_1"); + }); + + test("keys distinct sessions apart", () => { + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).toBe("sid:sess_2"); + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).not.toBe( + mcpAppsMarkerSubject(jwt({ sid: "sess_3" })), + ); + }); + + test("falls back to the token hash for API keys and sid-less JWTs", () => { + const apiKey = mcpAppsMarkerSubject("sk_test_key"); + expect(apiKey.startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject("sk_test_key")).toBe(apiKey); + expect(mcpAppsMarkerSubject("sk_other_key")).not.toBe(apiKey); + + const sidlessA = jwt({ sub: "user_1", exp: 100 }); + const sidlessB = jwt({ sub: "user_1", exp: 200 }); + expect(mcpAppsMarkerSubject(sidlessA).startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject(sidlessA)).not.toBe( + mcpAppsMarkerSubject(sidlessB), + ); + }); +}); diff --git a/src/lib/mcp-apps-marker.ts b/src/lib/mcp-apps-marker.ts new file mode 100644 index 0000000..6882361 --- /dev/null +++ b/src/lib/mcp-apps-marker.ts @@ -0,0 +1,39 @@ +import { createHmac } from "crypto"; + +function hashBearerToken(token: string): string { + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return createHmac("sha256", secretKey).update(token).digest("hex"); +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (normalized.length % 4)) % 4); + return JSON.parse( + Buffer.from(normalized + padding, "base64").toString("utf8"), + ) as Record; + } catch { + return null; + } +} + +/** + * Stable identity for a client's MCP Apps capability marker. OAuth access + * tokens rotate on refresh while the session (sid) survives, so key JWT + * sessions by sid; anything else (static API keys, sid-less JWTs) falls back + * to the token hash. The payload is only decoded, not verified: markers are + * recorded solely after the route layer verified the token, and gated calls + * only ever see verified tokens, so a sid here always belongs to the caller. + */ +export function mcpAppsMarkerSubject(token: string): string { + const claims = decodeJwtPayload(token); + if (claims && typeof claims.sid === "string" && claims.sid) { + return `sid:${claims.sid}`; + } + return `token:${hashBearerToken(token)}`; +} diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts new file mode 100644 index 0000000..c39957f --- /dev/null +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -0,0 +1,2 @@ +// Generated by scripts/build-managed-auth-app.mjs. Do not edit. +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx new file mode 100644 index 0000000..facd2c1 --- /dev/null +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -0,0 +1,895 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + AppearanceProvider, + KernelManagedAuth, + LocalizationProvider, + Shell, + StepError, + StepExpired, + StepPrime, + StepSuccess, +} from "@onkernel/managed-auth-react"; +import "@onkernel/managed-auth-react/styles.css"; +import { + failureTerminalView, + isTerminalFailure, +} from "./managed-auth-terminal"; + +const FAILURE_CONTEXT = + "Managed authentication stopped. Verify its terminal state and report the recovery option; do not continue the protected action."; + +type JsonObject = Record; +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; +type SafeConnection = { + id: string; + domain: string; + profile_name: string; + status: "AUTHENTICATED" | "NEEDS_AUTH"; + flow_status: + | "IN_PROGRESS" + | "SUCCESS" + | "FAILED" + | "EXPIRED" + | "CANCELED" + | null; + flow_type: "LOGIN" | "REAUTH" | null; + flow_expires_at: string | null; + error_code: string | null; +}; +type BeginResult = { + structuredContent?: { + state?: string; + connection?: SafeConnection; + started_new_flow?: boolean; + previous_flow_expires_at?: string | null; + resume_id?: string; + app_private?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + _meta?: { + auth_login?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + isError?: boolean; +}; +type WaitToolResult = { + structuredContent?: { + state?: "authenticated" | "failed" | "pending"; + connection?: SafeConnection | null; + }; + isError?: boolean; +}; + +let nextRequestId = 1; +const pendingRequests = new Map(); +// Reply target origin learned from the first validated host message. Outbound +// messages can carry capability-bearing URLs (the hosted fallback via +// ui/open-link), so once the host's origin is known we stop broadcasting to +// "*". The "*" fallback only exists because opaque-origin sandbox iframes +// cannot know the host origin before the first inbound message. +let hostOrigin: string | null = null; +let destroyed = false; +let collapsed = false; +let reactRoot: Root | null = null; +let completeToolInput: JsonObject | null = null; +let launcherToolResult: JsonObject | null = null; +let hostTheme: "light" | "dark" | "auto" = "auto"; +const listeners = new Set<() => void>(); +let stateVersion = 0; +const oneShotKeys = new Set(); + +function claimOneShot(key: string): boolean { + if (oneShotKeys.has(key)) return false; + try { + if (sessionStorage.getItem(key)) return false; + sessionStorage.setItem(key, "1"); + } catch { + // Opaque sandbox origins may deny storage; the in-memory guard still works. + } + oneShotKeys.add(key); + return true; +} + +function notifyStateChanged() { + stateVersion += 1; + for (const listener of listeners) listener(); +} + +function postToHost(message: JsonObject) { + // The "*" fallback is only reachable before the host's first inbound + // message (opaque-origin sandboxes cannot know the host origin earlier); + // those early messages are the ui/initialize handshake and carry no + // capability-bearing data. Everything after is pinned to hostOrigin. + // nosemgrep + window.parent.postMessage(message, hostOrigin ?? "*"); +} + +function sendRequest(method: string, params: JsonObject): Promise { + const id = nextRequestId++; + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + postToHost({ jsonrpc: "2.0", id, method, params }); + }); +} + +function sendNotification(method: string, params: JsonObject) { + postToHost({ jsonrpc: "2.0", method, params }); +} + +function callTool(name: string, args: JsonObject): Promise { + return sendRequest("tools/call", { + name, + arguments: args, + }) as Promise; +} + +function applyHostContext(context: JsonObject | undefined) { + const theme = context?.theme; + if (theme === "light" || theme === "dark") hostTheme = theme; + const styles = context?.styles as + | { variables?: Record } + | undefined; + if (styles?.variables) { + for (const [key, value] of Object.entries(styles.variables)) { + document.documentElement.style.setProperty(key, value); + } + } + notifyStateChanged(); +} + +function reportSize() { + const launcherReady = + completeToolInput !== null && launcherToolResult !== null; + sendNotification("ui/notifications/size-changed", { + height: + collapsed || !launcherReady + ? 1 + : Math.max(document.documentElement.scrollHeight, 360), + }); +} + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window.parent) return; + // Pin replies to the host's origin once known. Ignore "null" (opaque + // origins): they cannot be targeted, so the "*" fallback stays in effect. + if (!hostOrigin && event.origin && event.origin !== "null") { + hostOrigin = event.origin; + } + const message = event.data as + | { + jsonrpc?: string; + id?: number; + method?: string; + params?: JsonObject; + result?: unknown; + error?: { message?: string }; + } + | undefined; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id !== undefined && !message.method) { + const pending = pendingRequests.get(message.id); + if (!pending) return; + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? "Host request failed")); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "ui/resource-teardown" && message.id !== undefined) { + destroyed = true; + for (const pending of pendingRequests.values()) { + pending.reject(new Error("Managed-auth App was closed")); + } + pendingRequests.clear(); + listeners.clear(); + reactRoot?.unmount(); + reactRoot = null; + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + + if (message.id !== undefined && message.method) { + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + + switch (message.method) { + case "ui/notifications/tool-input": + completeToolInput = message.params?.arguments as JsonObject; + notifyStateChanged(); + break; + case "ui/notifications/tool-result": + launcherToolResult = message.params ?? null; + notifyStateChanged(); + break; + case "ui/notifications/host-context-changed": + applyHostContext(message.params); + break; + } +}); + +function useLauncherData() { + useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => stateVersion, + ); + return { + input: completeToolInput, + result: launcherToolResult, + theme: hostTheme, + }; +} + +function isRetryableHttpStatus(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +function sanitizeBeginArguments(input: JsonObject): JsonObject { + const allowed = [ + "mode", + "connection_id", + "domain", + "profile_name", + "save_credentials", + "record_session", + "browser_telemetry", + "proxy_id", + "proxy_name", + ]; + return Object.fromEntries( + allowed + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]]), + ); +} + +// Short long-poll so the panel reflects flow progress promptly; the model's +// own wait keeps its longer duration. +const APP_WAIT_SECONDS = 5; +const EDITABLE_FIELD_SELECTOR = [ + 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', + "textarea:not([disabled]):not([readonly])", + "select:not([disabled])", +].join(","); + +function waitArgumentsFromLauncher( + content: JsonObject | undefined, +): JsonObject | null { + const nextAction = content?.next_action as + | { tool?: unknown; arguments?: JsonObject } + | undefined; + if ( + nextAction?.tool !== "manage_auth_connections" || + nextAction.arguments?.action !== "wait" + ) { + return null; + } + const allowed = [ + "action", + "id", + "domain_filter", + "profile_name", + "required_flow_type", + "previous_flow_expires_at", + "previous_flow_event_id", + "flow_wait_started_at", + ]; + return { + ...Object.fromEntries( + allowed + .filter((key) => nextAction.arguments?.[key] !== undefined) + .map((key) => [key, nextAction.arguments?.[key]]), + ), + wait_seconds: APP_WAIT_SECONDS, + }; +} + +/** + * Poll arguments for the App: the launcher-supplied, baseline-guarded wait + * arguments, upgraded once begin has started a new flow — the selector + * tightens to the concrete connection id and carries the pre-flow baseline + * (plus a client-side timestamp so timeline identity can prove a new flow + * even when the API clears flow_expires_at before polling observes it). + */ +function pollWaitArguments( + launcherContent: JsonObject | undefined, + begin: BeginResult | null, + beginCalledAt: string | null, +): JsonObject | null { + const base = waitArgumentsFromLauncher(launcherContent); + if (!base) return null; + const content = begin?.structuredContent; + if (!content?.started_new_flow || !content.connection?.id) return base; + return { + action: "wait", + id: content.connection.id, + previous_flow_expires_at: content.previous_flow_expires_at ?? null, + ...(beginCalledAt && { flow_wait_started_at: beginCalledAt }), + wait_seconds: APP_WAIT_SECONDS, + }; +} + +function ManagedAuthApp() { + const launcher = useLauncherData(); + const [beginResult, setBeginResult] = useState(null); + const [starting, setStarting] = useState(false); + const [terminal, setTerminal] = useState<"success" | "failure" | null>(null); + const [terminalConnection, setTerminalConnection] = + useState(null); + const [dismissed, setDismissed] = useState(false); + const [statusText, setStatusText] = useState(""); + const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); + const [embeddedRetryRequired, setEmbeddedRetryRequired] = useState(false); + const pollTimer = useRef(null); + const pollingStarted = useRef(false); + const sawLiveFlow = useRef(false); + const beginCalledAt = useRef(null); + const mountedFlow = useRef(false); + const hostedFallbackAvailable = useRef(true); + const exchangedJwt = useRef(null); + const retrieveInitializationFailed = useRef(false); + + const launcherContent = launcher.result?.structuredContent as + | { + connection?: { domain?: string; profile_name?: string }; + next_action?: { tool?: string; arguments?: JsonObject }; + } + | undefined; + const targetDomain = + (launcherContent?.connection?.domain as string | undefined) ?? + (launcher.input?.domain as string | undefined) ?? + "this site"; + const connection = beginResult?.structuredContent?.connection; + const resumeId = beginResult?.structuredContent?.resume_id; + const privateAuth = + beginResult?._meta?.auth_login ?? + beginResult?.structuredContent?.app_private; + + const appearance = useMemo( + () => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }), + [launcher.theme], + ); + + const managedAuthFetch = useCallback( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const pathname = new URL(url, window.location.href).pathname; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const isExchange = + method === "POST" && + /\/auth\/connections\/[^/]+\/exchange$/.test(pathname); + const isRetrieve = + method === "GET" && /\/auth\/connections\/[^/]+$/.test(pathname); + + if (isExchange && exchangedJwt.current) { + return new Response(JSON.stringify({ jwt: exchangedJwt.current }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const attempts = isRetrieve ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(input, init); + if (isExchange && !isRetryableHttpStatus(response.status)) { + // A definitive exchange response means the hosted URL is no longer + // a safe fallback. Retryable failures leave it for the hosted UI. + hostedFallbackAvailable.current = false; + if (response.ok) { + const data = (await response.clone().json()) as { jwt?: unknown }; + if (typeof data.jwt === "string") exchangedJwt.current = data.jwt; + } + } + if (isRetrieve) { + if (response.ok) retrieveInitializationFailed.current = false; + else if ( + isRetryableHttpStatus(response.status) && + attempt + 1 < attempts + ) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } else { + retrieveInitializationFailed.current = isRetryableHttpStatus( + response.status, + ); + } + } + return response; + } catch (error) { + if (isRetrieve && attempt + 1 < attempts) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } + if (isRetrieve) retrieveInitializationFailed.current = true; + throw error; + } + } + throw new Error("Managed-auth request retry exhausted"); + }, + [], + ); + + useEffect(() => { + reportSize(); + }); + + useEffect(() => { + return () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }; + }, []); + + useEffect(() => { + const root = document.getElementById("root"); + if (!root || typeof MutationObserver === "undefined") return; + + let focusFrame: number | null = null; + const focusFirstEditableField = () => { + focusFrame = null; + const active = document.activeElement; + if ( + active instanceof Element && + root.contains(active) && + active.matches(EDITABLE_FIELD_SELECTOR) + ) { + return; + } + + const fields = root.querySelectorAll( + EDITABLE_FIELD_SELECTOR, + ); + const firstVisible = [...fields].find( + (field) => field.getClientRects().length > 0, + ); + firstVisible?.focus({ preventScroll: true }); + }; + const observer = new MutationObserver((mutations) => { + const addedEditableField = mutations.some((mutation) => + [...mutation.addedNodes].some( + (node) => + node instanceof Element && + (node.matches(EDITABLE_FIELD_SELECTOR) || + node.querySelector(EDITABLE_FIELD_SELECTOR) !== null), + ), + ); + if (addedEditableField && focusFrame === null) { + focusFrame = window.requestAnimationFrame(focusFirstEditableField); + } + }); + observer.observe(root, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + if (focusFrame !== null) window.cancelAnimationFrame(focusFrame); + }; + }, []); + + function terminalContext(outcome: "success" | "failure") { + if (outcome === "failure") { + return { + text: FAILURE_CONTEXT, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + }, + }; + } + + const domain = connection?.domain ?? targetDomain; + const profileName = connection?.profile_name; + const safeTarget = { + domain, + ...(profileName && { profile_name: profileName }), + }; + return { + text: `Kernel managed authentication reported completion for ${JSON.stringify(safeTarget)}. Verify the connection through manage_auth_connections before continuing the pending task.`, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + ...safeTarget, + }, + }; + } + + async function publishTerminal(outcome: "success" | "failure") { + if (!resumeId || destroyed) return; + const contextKey = `kernel-managed-auth-context:${resumeId}:${outcome}`; + if (!claimOneShot(contextKey)) return; + const context = terminalContext(outcome); + try { + await sendRequest("ui/update-model-context", { + content: [{ type: "text", text: context.text }], + structuredContent: context.structuredContent, + }); + } catch { + // The verified terminal state remains visible in the App. + } + } + + function finish(outcome: "success" | "failure", failed?: SafeConnection) { + if (terminal || destroyed) return; + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + if (failed) setTerminalConnection(failed); + setTerminal(outcome); + setStatusText(""); + void publishTerminal(outcome); + } + + // The App polls the launcher-supplied, baseline-guarded wait arguments and + // trusts the wait verdict; the returned SafeAuthConnection retains + // flow_status/error_code so the terminal step matches the hosted UI. + async function checkStatus() { + if (destroyed || terminal) return; + const args = pollWaitArguments( + launcher.result?.structuredContent as JsonObject | undefined, + beginResult, + beginCalledAt.current, + ); + const knownExpiry = connection?.flow_expires_at + ? Date.parse(connection.flow_expires_at) + : Number.NaN; + if (!args) { + if (Number.isFinite(knownExpiry) && knownExpiry <= Date.now()) { + finish("failure"); + return; + } + setStatusText("Checking secure login status…"); + pollTimer.current = window.setTimeout(checkStatus, 3000); + return; + } + try { + const wait = await callTool( + "manage_auth_connections", + args, + ); + const waitState = wait.structuredContent?.state; + const current = wait.structuredContent?.connection ?? null; + if (current?.flow_status === "IN_PROGRESS") sawLiveFlow.current = true; + if (waitState === "authenticated") { + finish("success"); + return; + } + if (waitState === "failed") { + finish("failure", current ?? undefined); + return; + } + // A pending wait can still carry a terminal flow it treated as stale + // (e.g. an unguarded selector whose flow failed before any poll saw it + // live). This App drove begin itself, so a terminal state it saw live — + // or one whose expiry moved past the pre-flow baseline — is the new + // flow's outcome. + if (current && isTerminalFailure(current.flow_status)) { + const baseline = beginResult?.structuredContent?.started_new_flow + ? beginResult.structuredContent.previous_flow_expires_at + : undefined; + const isNewFlowOutcome = + sawLiveFlow.current || + (baseline !== undefined && current.flow_expires_at !== baseline); + if (isNewFlowOutcome) { + finish("failure", current); + return; + } + } + setStatusText("Secure login is still in progress…"); + pollTimer.current = window.setTimeout(checkStatus, 1000); + } catch { + if (Number.isFinite(knownExpiry) && knownExpiry <= Date.now()) { + finish("failure"); + return; + } + setStatusText("Checking secure login status…"); + pollTimer.current = window.setTimeout(checkStatus, 3000); + } + } + + async function begin() { + if (!launcher.input || starting) { + setStatusText("Secure login input is unavailable. Close and retry."); + return; + } + setStarting(true); + setStatusText("Starting secure login…"); + try { + beginCalledAt.current = new Date().toISOString(); + const result = await callTool("begin_auth_login", { + ...sanitizeBeginArguments(launcher.input), + }); + if (result.isError || !result.structuredContent?.connection) { + throw new Error("Secure login could not be prepared"); + } + setBeginResult(result); + setStatusText(""); + } catch { + setStatusText( + "Secure login could not start. Close this panel and retry.", + ); + } finally { + setStarting(false); + } + } + + useEffect(() => { + if (!beginResult || !connection || terminal || pollingStarted.current) { + return; + } + if (beginResult.structuredContent?.state === "already_authenticated") { + finish("success"); + return; + } + if ( + beginResult.structuredContent?.state === "observing" || + !privateAuth?.handoff_code || + embeddedInitFailed + ) { + pollingStarted.current = true; + pollTimer.current = window.setTimeout(checkStatus, 500); + } + }); + + function closePanel() { + collapsed = true; + setDismissed(true); + window.setTimeout(reportSize, 0); + } + + if (dismissed) { + return