diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index aaa7bd8fb..4fc2a59e2 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -47,9 +47,9 @@ jobs: working-directory: studio run: npx vitest run - - name: Build + - name: Build + hermetic server-tier suite working-directory: studio - run: npm run build + run: npm run test:server - name: Audit working-directory: studio diff --git a/studio/.env.example b/studio/.env.example new file mode 100644 index 000000000..c403659c3 --- /dev/null +++ b/studio/.env.example @@ -0,0 +1,55 @@ +# Mecatl Studio — environment variables +# Copy this file to .env.local and fill in the values. +# +# Studio has two pure deployment modes: +# +# MANAGED (default): `npm run dev` (or `task studio:dev`) starts the local +# controller, which supervises a `mecated` it spawns from ../bin/mecated on a +# random loopback port with a generated bearer token. Requires `task build` +# at the repo root first. No variables are needed. +# +# EXTERNAL: set MECATL_BASE_URL and Studio proxies to that daemon instead. +# No controller runs; every local control surface (provider, model router, +# MCP gateway) answers 409 as owned by the deployment. + +# ── External mode ──────────────────────────────────────────────────────────── +# Base URL of an already-running mecated HTTP listener. Its presence selects +# external mode. +# MECATL_BASE_URL=https://mecated.internal.example.com:8081 + +# Bearer token for the external daemon (`mecated serve --auth-token ...`). +# Injected server-side only; the browser never sees or supplies it. +# MECATL_AUTH_TOKEN= + +# Absolute path every file and shell tool is rooted at, required on session +# create. Injected server-side so it never reaches the client bundle. +# MECATL_WORKSPACE=/absolute/path/to/workspace + +# ── Origin pinning (both modes) ────────────────────────────────────────────── +# Comma-separated list of origins Studio itself is served from. Requests whose +# Host or Origin fall outside this list are refused (CSRF/DNS-rebinding gate). +# MECATL_STUDIO_PUBLIC_ORIGIN=http://localhost:3000,http://127.0.0.1:3000 + +# Origin allowlist enforced by the local controller (managed mode). +# MECATL_STUDIO_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# ── Managed mode options ───────────────────────────────────────────────────── +# Provider for the supervised daemon: mock, openrouter, or toolhive. When +# unset the controller probes for a local ToolHive LLM gateway and otherwise +# falls back to the offline mock provider. Credentials are never entered in +# Studio: mecated reads them from ~/.config/mecatl/auth.yaml. +# MECATL_STUDIO_PROVIDER= + +# Set to 1 to allow an MCP gateway URL on loopback HTTP (HTTPS is otherwise +# required). +# MECATL_ALLOW_INSECURE_LOOPBACK_MCP= + +# ── Branding (optional) ────────────────────────────────────────────────────── +# Display name used as the logo's alt text. +# BRAND_NAME= + +# URL of a custom logo image, proxied server-side by /brand/logo. +# BRAND_LOGO_URL= + +# URL of a custom favicon (ICO, PNG, SVG, JPEG). +# FAVICON_URL= diff --git a/studio/package-lock.json b/studio/package-lock.json index 27113dab0..24f58a5de 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -35,6 +35,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.72.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "3.5.0" }, @@ -5383,6 +5384,12 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", diff --git a/studio/package.json b/studio/package.json index a4d2310fe..58cbc2537 100644 --- a/studio/package.json +++ b/studio/package.json @@ -6,15 +6,18 @@ "node": ">=22.13.0" }, "scripts": { + "dev": "node scripts/dev-local.mjs", "dev:web": "next dev", + "mecatl": "node scripts/local-controller.mjs", "build": "next build", + "start": "node scripts/dev-local.mjs --production", "lint": "biome check", "format": "biome format --write", "test": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "knip": "knip", - "dev": "next dev" + "test:server": "npm run build && node --test tests/rendered-html.test.mjs" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", @@ -44,6 +47,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.72.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "3.5.0" }, diff --git a/studio/scripts/dev-local.mjs b/studio/scripts/dev-local.mjs new file mode 100644 index 000000000..b4946bc62 --- /dev/null +++ b/studio/scripts/dev-local.mjs @@ -0,0 +1,82 @@ +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const controllerScript = resolve(root, "scripts/local-controller.mjs"); +const next = resolve(root, "node_modules/.bin/next"); +const production = process.argv.includes("--production"); +const externalMode = Boolean(process.env.MECATL_BASE_URL?.trim()); + +let stopping = false; +let controller = null; +let web = null; +let controllerRestart = null; +let webRestart = null; + +const delayRestart = (callback) => setTimeout(callback, 750); + +async function controllerIsHealthy() { + try { + const response = await fetch("http://127.0.0.1:8788/status", { + signal: AbortSignal.timeout(1_500), + }); + return response.ok; + } catch { + return false; + } +} + +async function ensureController() { + if (externalMode || stopping || controller || (await controllerIsHealthy())) + return; + controller = spawn(process.execPath, [controllerScript], { + cwd: root, + env: process.env, + stdio: "inherit", + }); + controller.once("exit", () => { + controller = null; + if (!stopping) + controllerRestart = delayRestart(() => { + controllerRestart = null; + void ensureController(); + }); + }); +} + +function startWeb() { + if (stopping || web) return; + web = spawn(next, production ? ["start"] : ["dev"], { + cwd: root, + env: process.env, + stdio: "inherit", + }); + web.once("exit", () => { + web = null; + if (!stopping) + webRestart = delayRestart(() => { + webRestart = null; + startWeb(); + }); + }); +} + +async function shutdown() { + if (stopping) return; + stopping = true; + if (controllerRestart) clearTimeout(controllerRestart); + if (webRestart) clearTimeout(webRestart); + clearInterval(healthCheck); + controller?.kill("SIGTERM"); + web?.kill("SIGTERM"); + setTimeout(() => process.exit(0), 2_000).unref(); +} + +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, shutdown); + +await ensureController(); +startWeb(); +const healthCheck = setInterval(() => { + void ensureController(); +}, 3_000); diff --git a/studio/scripts/local-controller.mjs b/studio/scripts/local-controller.mjs new file mode 100644 index 000000000..952fef5d4 --- /dev/null +++ b/studio/scripts/local-controller.mjs @@ -0,0 +1,1264 @@ +import { execFile, spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, open, readFile, rm } from "node:fs/promises"; +import http from "node:http"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + requestIsAllowed, + validateGatewayURL, +} from "../src/lib/controller-security.mjs"; +import { + KNOWN_AUTH_PROVIDERS, + listAuthFileProviders, + listSettingsProviders, +} from "../src/lib/provider-auth.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +// Studio is a module INSIDE the mecatl monorepo, so the harness it drives is +// the repo root one directory up — the workspace it edits, the source of +// `bin/mecated` (built by `task build`), and the cwd every run inherits. +const mecatlDir = resolve(here, "../.."); +const binary = resolve(mecatlDir, "bin/mecated"); +const workspace = mecatlDir; +const studioStateDir = resolve(here, "../.scratch"); +const operatorSettingsFile = resolve(studioStateDir, "operator-settings.yaml"); +const routerSettingsFile = resolve( + studioStateDir, + "model-router-settings.yaml", +); +const routerStateFile = resolve(studioStateDir, "model-router.json"); +// Project-scoped skills only. A SKILL.md steers the model the same way AGENTS.md +// does, so discovery is deliberately pinned to the workspace and we never pass +// --skills-conventional (which would also pull in ~/.claude/skills and the +// user-global mecatl dir — a much wider trust surface than this app should open). +const skillsDir = resolve(workspace, ".mecatl/skills"); +// Per-project memory (the Remember/Recall/SearchMemory tools) is OFF in mecated +// until --memory-dir is passed, unlike the user model which is on by default. The +// store is per-project by design, so it lives beside the session store rather than +// in a shared location. Consolidation stays off: it spends tokens in the background. +const memoryDir = resolve(workspace, ".scratch/studio-memory"); +const authFile = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME, "mecatl/auth.yaml") + : resolve(homedir(), ".config/mecatl/auth.yaml"); +// The user-global operator settings file, same XDG convention as auth.yaml. +// mecated always reads it at the operator tier; it is where a hand-added +// custom `providers:` block (ADR 0238) lives unless an imported +// operator-settings.yaml (a CLI-tier file, which wins whole-block) carries +// its own. +const userSettingsFile = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME, "mecatl/settings.yaml") + : resolve(homedir(), ".config/mecatl/settings.yaml"); +// mecated's --ready-file target: the atomically-published mecated-ready/1 +// document carrying the RESOLVED listener addresses (the daemon binds +// 127.0.0.1:0 and reports what the kernel picked), pid, api_major, features, +// and deployment. Written only after every listener is up, never removed by +// the daemon — the controller unlinks the stale one before each spawn. +const readyFilePath = resolve(studioStateDir, "mecated-ready.json"); +// The named FIFO backing --lifetime-pipe-fd. mecated fstat's the descriptor +// and rejects anything that is not a real pipe (S_IFIFO) — and Node's stdio +// "pipe" entries are AF_UNIX socketpairs — so the pipe is made with +// mkfifo(1) and its NAME is unlinked as soon as both ends are open. +const lifetimeFifoPath = resolve(studioStateDir, "mecated-lifetime.fifo"); +// How long to wait for the ready file. Remote MCP gateways may cold-start and +// mecatl gives their initialize handshake up to 30 seconds; MCP construction +// happens during composition, which completes BEFORE the ready file is +// written, so the wait stays comfortably longer than that. +const readyWaitMs = 60_000; + +/** auth.yaml's text, or "" when it does not exist / cannot be read. */ +async function readAuthFileText() { + try { + return await readFile(authFile, "utf8"); + } catch { + return ""; + } +} + +/** + * Names only of the providers configured in auth.yaml — never their values. + * The line scan lives in src/lib/provider-auth.mjs (shared with its vitest + * suite): it deliberately cannot read a credential, only detect that a + * `providers:` block names a key at one level of indent. + */ +async function listConfiguredProviderNames() { + return listAuthFileProviders(await readAuthFileText()).map( + (provider) => provider.name, + ); +} + +/** + * The operator-defined custom providers (ADR 0238) mecated will actually + * see, mirroring its whole-block first-non-nil `providers:` capture across + * the operator tier: the imported operator-settings.yaml (the CLI-tier file + * this controller passes) is consulted first, then the user-global + * settings.yaml. The section is non-secret by design — ids, flavors, base + * URLs, auth methods; any key stays in auth.yaml. + */ +async function listCustomSettingsProviders() { + const sources = operatorSettingsActive + ? [operatorSettingsFile, userSettingsFile] + : [userSettingsFile]; + for (const file of sources) { + let text; + try { + text = await readFile(file, "utf8"); + } catch { + continue; + } + const providers = listSettingsProviders(text); + if (providers !== null) return providers; + } + return []; +} + +/** + * Every provider name the daemon can be started on: the auth.yaml blocks + * plus the settings-defined custom providers — a keyless + * (`auth.method: none`) custom provider never appears in auth.yaml, so the + * auth scan alone would refuse to select it. + */ +async function listSelectableProviderNames() { + const names = await listConfiguredProviderNames(); + for (const provider of await listCustomSettingsProviders()) { + if (!names.includes(provider.name)) names.push(provider.name); + } + return names; +} + +// The kinds startMecatl/preferredKind understand: the two synthetic ones plus +// every provider auth.yaml can name a block for (KNOWN_AUTH_PROVIDERS is the +// same registry the guided-add UI offers). +const KNOWN_PROVIDER_KINDS = new Set([ + "mock", + "toolhive", + ...KNOWN_AUTH_PROVIDERS.map((entry) => entry.name), +]); +const configuredProvider = + process.env.MECATL_STUDIO_PROVIDER?.trim().toLowerCase() || ""; +if (configuredProvider && !KNOWN_PROVIDER_KINDS.has(configuredProvider)) { + throw new Error( + `MECATL_STUDIO_PROVIDER must be one of: ${[...KNOWN_PROVIDER_KINDS].join(", ")}`, + ); +} +// The LIVE active-provider selection. Seeded from MECATL_STUDIO_PROVIDER at +// startup, but — unlike that env var, which is frozen for the process's +// lifetime — reassignable at runtime through POST /providers/active (the +// Studio settings UI's provider switch), so an operator can move between the +// offline mock and a real provider without restarting `npm run dev`. +let activeProviderOverride = configuredProvider || null; +const managedAuthToken = ( + process.env.MECATL_AUTH_TOKEN || randomBytes(32).toString("base64url") +).replace(/^Bearer\s+/i, ""); +const mcpProxySecret = randomBytes(24).toString("base64url"); +const studioPublicOrigin = + process.env.MECATL_STUDIO_PUBLIC_ORIGIN?.trim() || "http://localhost:3000"; +const allowedOrigins = new Set( + ( + process.env.MECATL_STUDIO_ORIGINS || + "http://localhost:3000,http://127.0.0.1:3000" + ) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean), +); +allowedOrigins.add(studioPublicOrigin); +let child = null; +let provider = "offline mock"; +let mecatlBaseURL = ""; +// What the child's ready file reported at the last successful start: the +// non-secret compatibility descriptor a parent may surface (H1.3). Null +// until a child has published one. +let readyInfo = null; +let gateway = null; +let modelRouterConfig = null; +let operatorSettingsActive = false; +let startupLog = ""; +let restartQueue = Promise.resolve(); +let gatewayRefresh = null; +let shuttingDown = false; +let restartTimer = null; +let restartFailures = 0; +let startupError = ""; +const expectedExits = new WeakSet(); +const oauthAttempts = new Map(); +const oauthRedirectUri = "http://127.0.0.1:8788/oauth/callback"; +// The ToolHive LLM gateway reaches mecated through "thv llm proxy", a LOOPBACK +// reverse proxy that injects a fresh OIDC token per request. The controller +// never holds a gateway credential itself — that is the whole point of routing +// through the proxy rather than pasting a key. mecated auto-detects the same +// proxy from ToolHive's own config, so the port here is only used for the +// readiness probe that decides whether "toolhive" is an offerable provider. +const toolhiveGatewayURL = "http://127.0.0.1:14000/v1"; +let toolhiveReady = false; + +const delay = (milliseconds) => + new Promise((done) => setTimeout(done, milliseconds)); + +function jsonError(response, status, message) { + response.statusCode = status; + response.end(JSON.stringify({ error: message })); +} + +async function readBody(request, limit = 1_048_576) { + const declared = Number(request.headers["content-length"] || 0); + if (declared > limit) + throw Object.assign(new Error("request too large"), { statusCode: 413 }); + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > limit) + throw Object.assign(new Error("request too large"), { statusCode: 413 }); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +/** + * A REAL pipe for mecated's --lifetime-pipe-fd. The daemon fstat's the + * inherited descriptor and rejects anything that is not S_IFIFO — and Node's + * stdio "pipe" entries are AF_UNIX socketpairs — so the pipe is a named FIFO + * made with mkfifo(1), both ends opened, and the name unlinked (the + * descriptors outlive it). The controller holds the WRITE end and never + * writes: if this process dies — SIGKILL included — the kernel closes it, + * the child reads EOF, and mecated stops through its ordinary graceful + * shutdown. That is what keeps a controller crash from orphaning a daemon. + */ +async function createLifetimePipe() { + await rm(lifetimeFifoPath, { force: true }); + await new Promise((done, fail) => { + execFile("mkfifo", ["-m", "600", lifetimeFifoPath], (error) => + error ? fail(error) : done(), + ); + }); + try { + // Opening either end of a FIFO blocks until the other side opens, so the + // two opens must run concurrently; together they complete immediately. + const [readEnd, writeEnd] = await Promise.all([ + open(lifetimeFifoPath, "r"), + open(lifetimeFifoPath, "w"), + ]); + return { readEnd, writeEnd }; + } finally { + await rm(lifetimeFifoPath, { force: true }); + } +} + +/** + * Waits for THIS child's mecated-ready/1 document. The daemon publishes it + * atomically (temp + rename) and only after composition and every listener + * are up, so a successful read IS readiness — no connect polling, no + * stability window. A parse failure is a foreign file, never a torn write; + * a pid mismatch is the previous child's stale document (unlinked before the + * spawn, so only a pathological race shows one) and polling continues. + */ +async function waitForReadyDoc(proc) { + const deadline = Date.now() + readyWaitMs; + while (Date.now() < deadline) { + if (proc.exitCode !== null) + throw new Error(`mecatl exited during startup (code ${proc.exitCode})`); + if (child !== proc) + throw new Error("mecatl was replaced before it became ready"); + let text = ""; + try { + text = await readFile(readyFilePath, "utf8"); + } catch { + /* not published yet */ + } + if (text) { + let doc = null; + try { + doc = JSON.parse(text); + } catch { + /* not a ready document */ + } + if (doc && doc.schema !== "mecated-ready/1") + throw new Error( + `mecated wrote an unsupported ready-file schema "${doc.schema}"`, + ); + if (doc?.pid === proc.pid) { + if (typeof doc.http_address !== "string" || doc.http_address === "") + throw new Error("mecatl's ready file reports no HTTP listener"); + return doc; + } + } + await delay(100); + } + throw new Error("mecatl did not publish its ready file in time"); +} + +function fetchMecatl(path, options = {}) { + if (!mecatlBaseURL) + throw new Error("mecated has not been assigned a listener yet"); + const headers = new Headers(options.headers); + headers.set("authorization", `Bearer ${managedAuthToken}`); + return fetch(new URL(path, mecatlBaseURL), { ...options, headers }); +} + +function providerLabel(kind) { + if (kind === "mock") return "offline mock"; + if (kind === "toolhive") return "ToolHive LLM gateway"; + return ( + KNOWN_AUTH_PROVIDERS.find((entry) => entry.name === kind)?.label ?? kind + ); +} + +function startupFailure(kind, message) { + startupError = + kind !== "mock" && kind !== "toolhive" + ? `${providerLabel(kind)} could not start. Add providers.${kind}.api_key to ${authFile}, then switch to it again. mecated: ${message}` + : message; + return new Error(startupError); +} + +// A short probe, deliberately: when no token is cached the proxy blocks on an +// interactive browser login, and a controller start must never hang on that. +// Timing out simply means "not offerable right now" and studio falls back. +async function detectToolhiveGateway() { + try { + const response = await fetch(`${toolhiveGatewayURL}/models`, { + signal: AbortSignal.timeout(2500), + }); + return response.ok; + } catch { + return false; + } +} + +// Provider credentials are owned by mecated's conventional auth file, never +// copied through a browser form or patched into the child's environment here. +// MECATL_STUDIO_PROVIDER / the /providers/active switch select a provider +// without carrying its credential. +const preferredKind = () => + activeProviderOverride || (toolhiveReady ? "toolhive" : "mock"); + +function normalizeModelRouter(input) { + const classifierModel = + typeof input?.classifierModel === "string" + ? input.classifierModel.trim() + : ""; + if (!classifierModel || classifierModel.length > 200) + throw new Error("Choose a valid classifier model"); + if ( + !Array.isArray(input?.categories) || + input.categories.length < 2 || + input.categories.length > 8 + ) { + throw new Error("Semantic routing needs between 2 and 8 categories"); + } + const seen = new Set(); + const categories = input.categories.map((category) => { + const name = + typeof category?.name === "string" + ? category.name.trim().toLowerCase() + : ""; + const description = + typeof category?.description === "string" + ? category.description.trim() + : ""; + const model = + typeof category?.model === "string" ? category.model.trim() : ""; + if (!/^[a-z][a-z0-9_-]{0,39}$/.test(name)) + throw new Error( + "Category names must start with a letter and use only letters, numbers, underscores, or dashes", + ); + if (seen.has(name)) throw new Error(`Category name ${name} is duplicated`); + if (!description || description.length > 300) + throw new Error( + `Category ${name} needs a distinct description of at most 300 characters`, + ); + if (!model || model.length > 200) + throw new Error(`Choose a model for category ${name}`); + seen.add(name); + return { name, description, model }; + }); + const defaultCategory = + typeof input?.defaultCategory === "string" + ? input.defaultCategory.trim().toLowerCase() + : ""; + if (!seen.has(defaultCategory)) + throw new Error( + "The default category must match one of the routing categories", + ); + return { + enabled: input?.enabled !== false, + classifierModel, + defaultCategory, + categories, + }; +} + +async function loadModelRouter() { + try { + return normalizeModelRouter( + JSON.parse(await readFile(routerStateFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[router] saved configuration ignored: ${error.message || error}\n`, + ); + return null; + } +} + +async function hasOperatorSettings() { + try { + await readFile(operatorSettingsFile, "utf8"); + return true; + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[settings] operator configuration ignored: ${error.message || error}\n`, + ); + return false; + } +} + +function wellKnownURL(base, name) { + const url = new URL(base); + const issuerPath = + url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return new URL(`/.well-known/${name}${issuerPath}`, url.origin).toString(); +} + +async function fetchJSON(url) { + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + redirect: "follow", + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +function requireHttpsEndpoint(value, label) { + if (!value) throw new Error(`OAuth metadata does not include ${label}`); + const endpoint = new URL(value); + if (endpoint.protocol !== "https:") + throw new Error(`OAuth ${label} must use HTTPS`); + return endpoint.toString(); +} + +async function discoverOAuth(gatewayURL) { + const resourceCandidates = []; + try { + const probe = await fetch(gatewayURL, { + method: "GET", + headers: { Accept: "text/event-stream" }, + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + const challenge = probe.headers.get("www-authenticate") || ""; + const match = challenge.match( + /resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i, + ); + if (match?.[1] || match?.[2]) resourceCandidates.push(match[1] || match[2]); + await probe.body?.cancel().catch(() => undefined); + } catch { + /* fall through to RFC 9728 well-known locations */ + } + + resourceCandidates.push( + wellKnownURL(gatewayURL, "oauth-protected-resource"), + new URL( + "/.well-known/oauth-protected-resource", + gatewayURL.origin, + ).toString(), + ); + + let resourceMetadata = null; + for (const candidate of [...new Set(resourceCandidates)]) { + let candidateURL; + try { + candidateURL = requireHttpsEndpoint( + candidate, + "protected resource metadata URL", + ); + } catch { + continue; + } + resourceMetadata = await fetchJSON(candidateURL); + if (resourceMetadata) break; + } + + const authorizationServer = + resourceMetadata?.authorization_servers?.[0] || gatewayURL.origin; + const metadataCandidates = [ + wellKnownURL(authorizationServer, "oauth-authorization-server"), + wellKnownURL(authorizationServer, "openid-configuration"), + new URL( + "/.well-known/openid-configuration", + new URL(authorizationServer).origin, + ).toString(), + ]; + let metadata = null; + for (const candidate of [...new Set(metadataCandidates)]) { + metadata = await fetchJSON(candidate); + if (metadata) break; + } + if (!metadata) + throw new Error( + "The gateway did not advertise usable MCP OAuth authorization-server metadata", + ); + + const authorizationEndpoint = requireHttpsEndpoint( + metadata.authorization_endpoint, + "authorization endpoint", + ); + const tokenEndpoint = requireHttpsEndpoint( + metadata.token_endpoint, + "token endpoint", + ); + const registrationEndpoint = requireHttpsEndpoint( + metadata.registration_endpoint, + "dynamic client registration endpoint", + ); + const resourceScopes = Array.isArray(resourceMetadata?.scopes_supported) + ? resourceMetadata.scopes_supported + : []; + const authScopes = Array.isArray(metadata.scopes_supported) + ? metadata.scopes_supported + : []; + const defaultScopes = ["openid", "profile", "email", "offline_access"]; + const preferredAuthScopes = defaultScopes.filter((scope) => + authScopes.includes(scope), + ); + const scopes = [...new Set([...resourceScopes, ...preferredAuthScopes])]; + + return { + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint, + resource: resourceMetadata?.resource || gatewayURL.toString(), + scope: scopes.join(" ") || "openid profile email offline_access", + }; +} + +function queueRestart(operation) { + const run = restartQueue.then(operation, operation); + restartQueue = run.catch(() => undefined); + return run; +} + +function scheduleMecatlRestart() { + if (shuttingDown || restartTimer || child) return; + const wait = Math.min(10_000, 750 * 2 ** restartFailures); + process.stderr.write( + `[supervisor] mecated stopped unexpectedly; restarting in ${wait}ms\n`, + ); + restartTimer = setTimeout(() => { + restartTimer = null; + queueRestart(async () => { + if (shuttingDown || child) return; + try { + await startMecatl(preferredKind()); + restartFailures = 0; + process.stderr.write("[supervisor] mecated restarted\n"); + } catch (error) { + restartFailures += 1; + process.stderr.write( + `[supervisor] restart failed: ${error.message || error}\n`, + ); + scheduleMecatlRestart(); + } + }); + }, wait); +} + +async function refreshGatewayAccessToken(force = false) { + if (!gateway?.refreshToken || !gateway.clientId || !gateway.tokenEndpoint) + return false; + if (!force && gateway.expiresAt && gateway.expiresAt > Date.now() + 60_000) + return true; + if (gatewayRefresh) return gatewayRefresh; + gatewayRefresh = (async () => { + // No `scope` on refresh: RFC 6749 §6 makes it optional (the server reuses + // the originally granted scopes), and this provider rejects the request as + // malformed when it is present — which silently killed every auto-refresh + // and made gateway auth die on the hour. + const refreshBody = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: gateway.refreshToken, + client_id: gateway.clientId, + }); + if (gateway.resource) refreshBody.set("resource", gateway.resource); + const tokenResponse = await fetch(gateway.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: refreshBody, + }); + const tokenResult = await tokenResponse.json().catch(() => ({})); + if (!tokenResponse.ok || !tokenResult.access_token) { + process.stderr.write( + `[oauth] refresh rejected (${tokenResponse.status}, ${String(tokenResult.error || "unknown_error").slice(0, 80)})\n`, + ); + throw new Error( + tokenResult.error_description || + tokenResult.error || + "Gateway token refresh failed", + ); + } + gateway.token = tokenResult.access_token; + if (tokenResult.refresh_token) + gateway.refreshToken = tokenResult.refresh_token; + gateway.expiresAt = + Date.now() + Math.max(60, Number(tokenResult.expires_in) || 300) * 1000; + process.stderr.write("[oauth] gateway access token refreshed\n"); + return true; + })().finally(() => { + gatewayRefresh = null; + }); + return gatewayRefresh; +} + +async function stopChild() { + if (!child) { + await delay(200); + return; + } + const current = child; + expectedExits.add(current); + await new Promise((done) => { + const timer = setTimeout(() => { + current.kill("SIGKILL"); + done(); + }, 2000); + current.once("exit", () => { + clearTimeout(timer); + done(); + }); + current.kill("SIGTERM"); + }); + if (child === current) child = null; + // Let loopback listeners finish closing before the next child binds them. + await delay(250); +} + +async function startMecatl(kind) { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + await stopChild(); + startupLog = ""; + startupError = ""; + // No listener is assigned until THIS child's ready file names one: the old + // base URL points at a dead port, and fetchMecatl's guard is the honest + // answer while the restart is in flight. + mecatlBaseURL = ""; + readyInfo = null; + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + // The daemon never removes its ready file (a SIGKILLed one could not), so + // the previous child's document must go before the spawn — otherwise the + // wait below could read yesterday's addresses. The pid check is the + // backstop for the pathological race. + await rm(readyFilePath, { force: true }); + const args = [ + "serve", + "--workspace", + workspace, + "--store-dir", + ".scratch/studio-sessions", + "--grpc-addr", + "127.0.0.1:0", + // An ephemeral HTTP port: the kernel picks it at bind(2) time and the + // ready file reports it RESOLVED — no pre-bind pick, no TOCTOU window. + "--http-addr", + "127.0.0.1:0", + "--ready-file", + readyFilePath, + ]; + if (operatorSettingsActive) + args.push("--permission-config", operatorSettingsFile); + else if (modelRouterConfig) + args.push("--permission-config", routerSettingsFile); + // mecated refuses to start on a missing --skills-dir, and an empty directory is + // the correct "no skills yet" state, so create it before every spawn. + await mkdir(skillsDir, { recursive: true }); + args.push("--skills-dir", skillsDir); + await mkdir(memoryDir, { recursive: true }); + args.push("--memory-dir", memoryDir); + if (kind === "mock") { + args.push("--mock"); + } else if (kind === "toolhive") { + // No credential and no base-URL flag for the gateway: mecated finds the + // loopback proxy through ToolHive's own config and registers it as the + // "toolhive" provider on its own. Naming it as the default is all it takes. + args.push("--default-provider", "toolhive"); + } else { + // Any auth.yaml-configured provider (openrouter, anthropic, openai, + // opencode, …) or a settings-defined custom provider (ADR 0238) — + // mecated validates the id fail-fast at startup. + args.push("--default-provider", kind); + } + const env = { ...process.env, MECATL_AUTH_TOKEN: managedAuthToken }; + if (gateway) { + // Mecatl's SDK opens the optional standalone SSE notification stream after + // initialization. Some authenticated gateways (including Connector Gateway) + // close that GET stream and thereby cancel an otherwise valid MCP session. + // Keep the optional stream on loopback and forward request/response traffic. + args.push( + "--mcp-server", + `${gateway.name}=http://127.0.0.1:8788/mcp-proxy/${mcpProxySecret}/${encodeURIComponent(gateway.name)}`, + ); + } + // The lifetime pipe is best-effort: without mkfifo(1) the daemon still + // starts, it just loses the parent-crash cleanup (SIGINT/SIGTERM reaping + // below still covers the clean paths). + let lifetime = null; + try { + lifetime = await createLifetimePipe(); + // The FIFO's read end lands at fd 3 in the child (stdio index 3 below). + args.push("--lifetime-pipe-fd", "3"); + } catch (error) { + process.stderr.write( + `[supervisor] lifetime pipe unavailable (${error.message || error}); spawning without parent-crash protection\n`, + ); + } + const proc = spawn(binary, args, { + cwd: mecatlDir, + env, + stdio: [ + "ignore", + "ignore", + "pipe", + ...(lifetime ? [lifetime.readEnd.fd] : []), + ], + }); + child = proc; + if (lifetime) { + // The child owns its duplicate of the read end now; the controller keeps + // ONLY the write end, open and never written to, until this child exits. + void lifetime.readEnd.close().catch(() => undefined); + const writeEnd = lifetime.writeEnd; + const releaseWriteEnd = () => void writeEnd.close().catch(() => undefined); + proc.once("exit", releaseWriteEnd); + proc.once("error", releaseWriteEnd); + } + proc.stderr.on("data", (chunk) => { + const text = chunk.toString(); + startupLog = (startupLog + text).slice(-24_000); + process.stderr.write(`[mecatl] ${text}`); + }); + proc.once("exit", () => { + if (child === proc) child = null; + if (!expectedExits.has(proc)) scheduleMecatlRestart(); + }); + provider = providerLabel(kind); + // The ready file replaces the old connect-poll + stability window: it is + // written atomically and only after composition and every listener are up, + // so its appearance IS readiness and its http_address arrives resolved. + let doc; + try { + doc = await waitForReadyDoc(proc); + } catch (error) { + throw startupFailure(kind, error.message || "mecatl did not become ready"); + } + mecatlBaseURL = `http://${doc.http_address}`; + readyInfo = { + apiMajor: Number(doc.api_major ?? 0), + features: Array.isArray(doc.features) + ? doc.features.filter((feature) => typeof feature === "string") + : [], + deployment: typeof doc.deployment === "string" ? doc.deployment : "", + }; + // MCP gateway construction happens during composition, BEFORE the ready + // file is written — so when the daemon came up degraded rather than dead, + // the handshake failure is already in the startup log. + if (gateway && /Unauthorized/i.test(startupLog)) { + throw new Error( + "MCP Gateway rejected the bearer token (Unauthorized). Paste a current gateway access token and try again.", + ); + } + if ( + gateway && + /MCP manager construction failed|no servers could be connected/i.test( + startupLog, + ) + ) { + throw new Error( + "MCP Gateway could not be initialized. Check that the URL is a Streamable HTTP endpoint and that its credential is valid.", + ); + } + restartFailures = 0; +} + +const server = http.createServer(async (request, response) => { + const requestURL = new URL(request.url, "http://127.0.0.1:8788"); + const mcpProxyPrefix = `/mcp-proxy/${mcpProxySecret}/`; + if ( + !requestIsAllowed(request, requestURL, { allowedOrigins, mcpProxyPrefix }) + ) { + jsonError(response, 403, "request origin is not allowed"); + return; + } + if (requestURL.pathname.startsWith("/mecatl/")) { + try { + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await readBody(request); + const headers = {}; + for (const key of [ + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + "last-event-id", + ]) { + if (request.headers[key]) headers[key] = request.headers[key]; + } + const path = + requestURL.pathname.slice("/mecatl".length) + requestURL.search; + const upstream = await fetchMecatl(path, { + method: request.method, + headers, + body, + redirect: "manual", + }); + response.statusCode = upstream.status; + for (const key of [ + "content-type", + "cache-control", + "mcp-session-id", + "www-authenticate", + ]) { + const value = upstream.headers.get(key); + if (value) response.setHeader(key, value); + } + if (upstream.body) + for await (const chunk of upstream.body) response.write(chunk); + response.end(); + } catch (error) { + jsonError( + response, + error.statusCode || 502, + error.message || "mecated proxy failed", + ); + } + return; + } + if (requestURL.pathname.startsWith(mcpProxyPrefix)) { + const proxyName = decodeURIComponent( + requestURL.pathname.slice(mcpProxyPrefix.length), + ); + if (!gateway || proxyName !== gateway.name) { + response.statusCode = 404; + response.end("gateway not configured"); + return; + } + if (request.method === "GET") { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + response.write(": loopback notification channel\n\n"); + const heartbeat = setInterval( + () => response.write(": keepalive\n\n"), + 15_000, + ); + request.once("close", () => clearInterval(heartbeat)); + return; + } + if (!["POST", "DELETE"].includes(request.method || "")) { + response.statusCode = 405; + response.end("method not allowed"); + return; + } + try { + const requestBody = + request.method === "POST" ? await readBody(request) : undefined; + await refreshGatewayAccessToken(false); + const headers = { Authorization: `Bearer ${gateway.token}` }; + for (const key of [ + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + "last-event-id", + ]) { + if (request.headers[key]) headers[key] = request.headers[key]; + } + let upstream = await fetch(gateway.url, { + method: request.method, + headers, + body: requestBody, + }); + if (upstream.status === 401 && gateway.refreshToken) { + await upstream.arrayBuffer(); + await refreshGatewayAccessToken(true); + headers.Authorization = `Bearer ${gateway.token}`; + upstream = await fetch(gateway.url, { + method: request.method, + headers, + body: requestBody, + }); + } + process.stderr.write( + `[mcp-proxy] ${request.method} ${upstream.status} ${upstream.headers.get("content-type") || ""}\n`, + ); + response.statusCode = upstream.status; + for (const key of [ + "content-type", + "cache-control", + "mcp-session-id", + "www-authenticate", + ]) { + const value = upstream.headers.get(key); + if (value) response.setHeader(key, value); + } + if (upstream.body) { + for await (const chunk of upstream.body) response.write(chunk); + } + response.end(); + } catch (error) { + process.stderr.write( + `[mcp-proxy] ${request.method} failed: ${error.message || error}\n`, + ); + jsonError( + response, + error.statusCode || 502, + error.message || "gateway proxy failed", + ); + } + return; + } + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + if (request.method === "GET" && requestURL.pathname === "/mcp/oauth/start") { + try { + const name = requestURL.searchParams.get("name") || ""; + const gatewayURL = new URL(requestURL.searchParams.get("url") || ""); + if (!/^[A-Za-z0-9_]+$/.test(name)) + throw new Error( + "Gateway name may contain only letters, numbers, and underscores", + ); + if (gatewayURL.protocol !== "https:") + throw new Error("OAuth gateways must use HTTPS"); + const discovery = await discoverOAuth(gatewayURL); + const registrationResponse = await fetch(discovery.registrationEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Mecatl Studio", + application_type: "native", + redirect_uris: [oauthRedirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + signal: AbortSignal.timeout(10_000), + }); + const registration = await registrationResponse.json().catch(() => ({})); + if (!registrationResponse.ok) + throw new Error( + registration.error_description || + registration.error || + "Gateway refused OAuth client registration", + ); + if (!registration.client_id) + throw new Error("Gateway registration did not return a client ID"); + const state = randomBytes(24).toString("base64url"); + const verifier = randomBytes(48).toString("base64url"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + const scope = registration.scope || discovery.scope; + oauthAttempts.set(state, { + name, + url: gatewayURL.toString(), + clientId: registration.client_id, + tokenEndpoint: discovery.tokenEndpoint, + scope, + resource: discovery.resource, + verifier, + expires: Date.now() + 10 * 60_000, + }); + const authorizationURL = new URL(discovery.authorizationEndpoint); + authorizationURL.searchParams.set("response_type", "code"); + authorizationURL.searchParams.set("client_id", registration.client_id); + authorizationURL.searchParams.set("redirect_uri", oauthRedirectUri); + authorizationURL.searchParams.set("scope", scope); + if (discovery.resource) + authorizationURL.searchParams.set("resource", discovery.resource); + authorizationURL.searchParams.set("state", state); + authorizationURL.searchParams.set("code_challenge", challenge); + authorizationURL.searchParams.set("code_challenge_method", "S256"); + if (requestURL.searchParams.get("redirect") === "1") { + response.writeHead(302, { Location: authorizationURL.toString() }); + response.end(); + } else { + response.end( + JSON.stringify({ authorizationUrl: authorizationURL.toString() }), + ); + } + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ + error: error.message || "Could not start gateway sign-in", + }), + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/oauth/callback") { + response.setHeader("Content-Type", "text/html; charset=utf-8"); + const state = requestURL.searchParams.get("state") || ""; + const attempt = oauthAttempts.get(state); + oauthAttempts.delete(state); + try { + if (requestURL.searchParams.get("error")) + throw new Error( + requestURL.searchParams.get("error_description") || + requestURL.searchParams.get("error"), + ); + if (!attempt || attempt.expires < Date.now()) + throw new Error( + "The gateway sign-in attempt expired. Start it again from Mecatl Studio.", + ); + const code = requestURL.searchParams.get("code"); + if (!code) + throw new Error("Gateway sign-in did not return an authorization code"); + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: attempt.clientId, + redirect_uri: oauthRedirectUri, + code_verifier: attempt.verifier, + }); + if (attempt.resource) tokenBody.set("resource", attempt.resource); + const tokenResponse = await fetch(attempt.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody, + signal: AbortSignal.timeout(15_000), + }); + const tokenResult = await tokenResponse.json().catch(() => ({})); + if (!tokenResponse.ok || !tokenResult.access_token) + throw new Error( + tokenResult.error_description || + tokenResult.error || + "Gateway token exchange failed", + ); + await queueRestart(async () => { + const previousGateway = gateway; + gateway = { + name: attempt.name, + url: attempt.url, + token: tokenResult.access_token, + refreshToken: tokenResult.refresh_token || "", + clientId: attempt.clientId, + tokenEndpoint: attempt.tokenEndpoint, + scope: attempt.scope, + resource: attempt.resource, + expiresAt: + Date.now() + + Math.max(60, Number(tokenResult.expires_in) || 300) * 1000, + }; + try { + await startMecatl(preferredKind()); + } catch (error) { + gateway = previousGateway; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end( + `Mecatl Gateway Connected

Gateway connected

You can close this window.

`, + ); + } catch (error) { + const message = String(error.message || "Gateway sign-in failed").replace( + /[<>&"']/g, + "", + ); + process.stderr.write(`[oauth] callback failed: ${message}\n`); + response.statusCode = 400; + response.end( + `Mecatl Gateway Error

Could not connect

${message}

`, + ); + } + return; + } + if ( + request.method === "POST" && + requestURL.pathname === "/mcp/oauth/refresh" + ) { + try { + if (!gateway?.refreshToken) + throw new Error("Gateway does not have a refresh token; sign in again"); + await refreshGatewayAccessToken(true); + response.end(JSON.stringify({ ok: true })); + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ + error: error.message || "Gateway token refresh failed", + }), + ); + } + return; + } + // Restart mecated with the CURRENT provider, gateway and router config. The + // daemon resolves skills and agent definitions once at startup (ListSkills is + // a pure snapshot read), so a newly authored SKILL.md only reaches the model + // after a restart. Provider credentials remain owned by mecated's auth file. + if (request.method === "POST" && requestURL.pathname === "/restart") { + try { + await queueRestart(async () => { + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, provider })); + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ error: error.message || "Could not restart mecated" }), + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/status") { + const configuredProviders = await listSelectableProviderNames(); + response.end( + JSON.stringify({ + mode: "managed", + provider, + isMock: provider === "offline mock", + running: Boolean(child), + startupError, + authFile, + // Where a custom `providers:` block lands (ADR 0238): the imported + // operator-settings.yaml when one is active (a CLI-tier file wins + // that section whole-block), otherwise the user-global settings. + settingsFile: operatorSettingsActive + ? operatorSettingsFile + : userSettingsFile, + // The child's ready-file compatibility descriptor (never a + // credential): api_major, feature identifiers, and the operator's + // deployment label, verbatim from mecated-ready/1. + apiMajor: readyInfo?.apiMajor ?? null, + features: readyInfo?.features ?? [], + deployment: readyInfo?.deployment ?? "", + // Names only — never values. What MECATL_STUDIO_PROVIDER / the + // /providers/active switch may select among (auth.yaml blocks plus + // settings-defined custom providers), and which one is active right + // now, if any. + configuredProviders, + selectedProvider: activeProviderOverride, + // The client has no other way to learn this: it is resolved from THIS + // file's location, so a clone anywhere works with no source edit. + workspace, + gateway: gateway ? { name: gateway.name, url: gateway.url } : null, + toolhiveGateway: { + available: toolhiveReady, + baseURL: toolhiveGatewayURL, + active: provider === "ToolHive LLM gateway", + }, + modelRouter: modelRouterConfig + ? { + enabled: modelRouterConfig.enabled, + categories: modelRouterConfig.categories.length, + } + : null, + operatorSettings: operatorSettingsActive, + skills: { dir: skillsDir, scope: "project" }, + memory: { dir: memoryDir, scope: "project" }, + }), + ); + return; + } + if (request.method !== "POST" || requestURL.pathname !== "/mcp") { + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + return; + } + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) { + throw Object.assign(new Error("Content-Type must be application/json"), { + statusCode: 415, + }); + } + const input = JSON.parse( + (await readBody(request, 16_384)).toString("utf8"), + ); + if (!/^[A-Za-z0-9_]+$/.test(input.name || "")) + throw new Error( + "Gateway name may contain only letters, numbers, and underscores", + ); + const parsed = validateGatewayURL(input.url, { + allowLoopbackHTTP: process.env.MECATL_ALLOW_INSECURE_LOOPBACK_MCP === "1", + }); + const token = + typeof input.token === "string" + ? input.token.trim().replace(/^Bearer\s+/i, "") + : ""; + const candidate = { name: input.name, url: parsed.toString(), token }; + await queueRestart(async () => { + const previousGateway = gateway; + gateway = candidate; + try { + await startMecatl(preferredKind()); + } catch (error) { + // Keep the provider usable and keep rejected gateway credentials out of + // controller state. The caller still receives the original handshake error. + gateway = previousGateway; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end( + JSON.stringify({ + ok: true, + gateway: { name: gateway.name, url: gateway.url }, + }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the Mecatl controller", + ); + } +}); + +server.listen(8788, "127.0.0.1", async () => { + process.stdout.write("Mecatl local controller: http://127.0.0.1:8788\n"); + operatorSettingsActive = await hasOperatorSettings(); + modelRouterConfig = await loadModelRouter(); + toolhiveReady = await detectToolhiveGateway(); + process.stdout.write( + toolhiveReady + ? `ToolHive LLM gateway detected at ${toolhiveGatewayURL}\n` + : `ToolHive LLM gateway not reachable at ${toolhiveGatewayURL} (start it with "thv llm proxy start"); falling back to the offline mock\n`, + ); + try { + await startMecatl(preferredKind()); + } catch (error) { + startupError ||= error.message || "mecated could not start"; + process.stderr.write(`${startupError}\n`); + } +}); + +// Clean-exit reaping. The CRASH path needs none of this: the lifetime pipe's +// write end dies with this process — SIGKILL included — and mecated reads EOF +// and shuts itself down, so a controller crash can no longer orphan a daemon. +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, async () => { + shuttingDown = true; + if (restartTimer) clearTimeout(restartTimer); + await stopChild(); + server.close(() => process.exit(0)); + }); +} diff --git a/studio/src/app/api/mecatl-control/[...path]/route.ts b/studio/src/app/api/mecatl-control/[...path]/route.ts new file mode 100644 index 000000000..3bb3061b3 --- /dev/null +++ b/studio/src/app/api/mecatl-control/[...path]/route.ts @@ -0,0 +1,14 @@ +import { proxyControl } from "@/lib/server-proxy"; + +type Context = { params: Promise<{ path: string[] }> }; + +const handle = async (request: Request, context: Context) => + proxyControl(request, (await context.params).path); + +export { + handle as DELETE, + handle as GET, + handle as PATCH, + handle as POST, + handle as PUT, +}; diff --git a/studio/src/app/api/mecatl/[...path]/route.ts b/studio/src/app/api/mecatl/[...path]/route.ts new file mode 100644 index 000000000..e6dff5be2 --- /dev/null +++ b/studio/src/app/api/mecatl/[...path]/route.ts @@ -0,0 +1,14 @@ +import { proxyMecatl } from "@/lib/server-proxy"; + +type Context = { params: Promise<{ path: string[] }> }; + +const handle = async (request: Request, context: Context) => + proxyMecatl(request, (await context.params).path); + +export { + handle as DELETE, + handle as GET, + handle as PATCH, + handle as POST, + handle as PUT, +}; diff --git a/studio/src/lib/controller-security.mjs b/studio/src/lib/controller-security.mjs new file mode 100644 index 000000000..80f3d56a1 --- /dev/null +++ b/studio/src/lib/controller-security.mjs @@ -0,0 +1,69 @@ +export function isLoopbackHost(hostname) { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +export function requestIsAllowed( + request, + requestURL, + { allowedOrigins, mcpProxyPrefix }, +) { + let host; + try { + host = new URL(`http://${request.headers.host || ""}`).hostname; + } catch { + return false; + } + if (!isLoopbackHost(host)) return false; + const origin = request.headers.origin; + if (origin && !allowedOrigins.has(origin)) return false; + + const callback = + request.method === "GET" && requestURL.pathname === "/oauth/callback"; + const internalMCP = requestURL.pathname.startsWith(mcpProxyPrefix); + const readOnly = + request.method === "GET" && + ["/status", "/model-router"].includes(requestURL.pathname); + return ( + callback || + internalMCP || + readOnly || + request.headers["x-mecatl-studio-request"] === "1" + ); +} + +/** + * The daemon's skill activation-name grammar, mirrored byte-for-byte from + * `engine/adapter/skillfs/name.go` (`ValidSkillName`): lowercase + * letters/digits/underscore/hyphen, 1-64 chars, starting with a lowercase + * letter or digit. Names under this grammar cannot contain a path separator, + * a dot, or whitespace, so a valid name is safe to join onto the pinned + * skills directory — the controller AND the browser client validate through + * this ONE regex so the two tiers cannot drift. + * + * @param {string} name + * @returns {boolean} + */ +export function validSkillName(name) { + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name); +} + +export function validateGatewayURL(value, { allowLoopbackHTTP = false } = {}) { + const parsed = new URL(value); + if (parsed.username || parsed.password) + throw new Error("Gateway URLs must not contain credentials"); + if (parsed.protocol === "https:") return parsed; + if ( + parsed.protocol === "http:" && + isLoopbackHost(parsed.hostname) && + allowLoopbackHTTP + ) + return parsed; + throw new Error( + "Gateway URLs must use HTTPS. Loopback HTTP requires MECATL_ALLOW_INSECURE_LOOPBACK_MCP=1 on the controller.", + ); +} diff --git a/studio/src/lib/controller-security.test.ts b/studio/src/lib/controller-security.test.ts new file mode 100644 index 000000000..e10a5893a --- /dev/null +++ b/studio/src/lib/controller-security.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { validSkillName } from "./controller-security.mjs"; + +/** + * The shared skill-name gate — the ONE grammar (mirroring the daemon's + * skillfs.ValidSkillName) that both the controller's filesystem endpoints and + * the browser client validate through. Everything the filesystem side relies + * on (no separators, no dots, no traversal) must hold here, because a valid + * name is joined directly onto the pinned skills directory. + */ +describe("validSkillName", () => { + it("accepts the daemon's activation-name grammar", () => { + for (const name of [ + "a", + "pr-feedback", + "skill_2", + "0day-notes", + "x".repeat(64), + ]) { + expect(validSkillName(name), name).toBe(true); + } + }); + + it("rejects path traversal and separators", () => { + for (const name of [ + "..", + ".", + "../evil", + "a/../b", + "a/b", + "a\\b", + ".disabled", + ".hidden", + "name.md", + ]) { + expect(validSkillName(name), name).toBe(false); + } + }); + + it("rejects whitespace, case, emptiness, and oversized names", () => { + for (const name of [ + "", + " ", + "two words", + "Upper", + "tab\tname", + "new\nline", + "-leading-dash", + "_leading-underscore", + "x".repeat(65), + ]) { + expect(validSkillName(name), JSON.stringify(name)).toBe(false); + } + }); +}); diff --git a/studio/src/lib/provider-auth.mjs b/studio/src/lib/provider-auth.mjs new file mode 100644 index 000000000..60e19c5a2 --- /dev/null +++ b/studio/src/lib/provider-auth.mjs @@ -0,0 +1,399 @@ +// Pure helpers over mecated's auth.yaml (`providers:` block) shared by the +// managed-mode controller and its vitest suite. Everything here is a LINE +// SCAN, never a YAML parse, mirroring listConfiguredProviderNames in +// scripts/local-controller.mjs — and, deliberately, nothing in this module +// can RETURN a credential: inventory reports booleans, and removal returns +// the file with a block cut out. Reading a key VALUE (for the controller's +// server-side key test) lives in the controller script only, never in a +// module the browser bundle may import (Studio rule 3: credentials never +// cross the browser/controller boundary). + +/** + * The BUILT-IN provider kinds mecated's auth.yaml understands, mirrored from + * the Go side (`internal/cliconfig/cliconfig.go` knownAuthProviders: + * "anthropic", "openai", "openrouter", "opencode", "openai-codex"). That set + * is no longer closed: since ADR 0238 an operator can define CUSTOM providers + * in the operator settings.yaml `providers:` section, and the daemon's + * permitted auth.yaml ids become knownAuthProviders PLUS those custom ids + * (`internal/cliconfig` ResolveProviderCredentials). This registry therefore + * lists only the guided-add BUILT-INS — custom gateways ride the "Custom + * gateway" flow (validCustomProviderId + the snippet builders below) and are + * discovered from the settings file, not from this list. There is no + * machine-readable source the controller could read at runtime, so the + * built-ins are pinned here with this citation; extend the list when the + * daemon's BUILT-IN set grows. Each entry carries the exact auth.yaml + * snippet the guided-add dialog shows — with a `` placeholder, + * never a real value. `testable` marks kinds the controller can key-test + * with one cheap authenticated call ("toolhive" is absent: it is + * auto-detected from ToolHive's own config and never appears in auth.yaml). + */ +export const KNOWN_AUTH_PROVIDERS = [ + { + name: "openrouter", + label: "OpenRouter", + testable: true, + snippet: "providers:\n openrouter:\n api_key: \n", + note: "Create a key at openrouter.ai/keys.", + }, + { + name: "anthropic", + label: "Anthropic", + testable: true, + snippet: "providers:\n anthropic:\n api_key: \n", + note: "An Anthropic API key (console.anthropic.com).", + }, + { + name: "openai", + label: "OpenAI", + testable: true, + snippet: "providers:\n openai:\n api_key: \n", + note: "An OpenAI API key (platform.openai.com).", + }, + { + name: "opencode", + label: "OpenCode Go", + testable: true, + snippet: "providers:\n opencode:\n api_key: \n", + note: "An OpenCode Go gateway key.", + }, + { + name: "openai-codex", + label: "OpenAI Codex subscription", + testable: false, + // The oauth block is a manually supplied ChatGPT Codex subscription token + // (docs/usage.md "OpenAI Codex subscription"), not a long-lived API key — + // which is also why the controller refuses to key-test it: a merely + // EXPIRED token would read as "rejected" and send the operator chasing a + // non-problem. + snippet: + "providers:\n openai-codex:\n oauth:\n access_token: \n account_id: \n expires_at: \n", + note: "A manually supplied ChatGPT Codex subscription token; see the mecatl usage docs for the copy-in steps.", + }, +]; + +/** The daemon's provider-name grammar as the controller's routes accept it. */ +export function validProviderName(name) { + return /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name); +} + +// ── Custom ("Custom gateway") provider helpers — ADR 0238 ─────────────────── +// Operator-defined providers live in the operator settings.yaml `providers:` +// section (NON-secret by design: id, base_url, default_model, api_flavor, +// auth.method) with any api_key in auth.yaml under `providers..api_key`. +// Everything here mirrors the daemon's strict parse +// (`internal/adapter/permconfig/providers.go`) so a snippet Studio emits +// never fails mecated's startup validation — and, like the rest of this +// module, none of it can return or carry a credential. + +/** The daemon's closed `api_flavor` enum, in the order the dialog offers. */ +export const CUSTOM_PROVIDER_API_FLAVORS = [ + "openai-responses", + "openai-chat-completions", + "anthropic-messages", +]; + +/** Ids the daemon reserves for built-ins — a custom entry may not take them + * (`internal/adapter/permconfig/providers.go` reservedProviderIDs). */ +export const RESERVED_CUSTOM_PROVIDER_IDS = [ + "anthropic", + "mock", + "openai", + "openai-codex", + "openrouter", + "opencode", + "toolhive", +]; + +/** The daemon's custom-provider id grammar: a lower-case DNS-label-like name + * (`providerIDPattern`), minus the reserved built-in ids. */ +export function validCustomProviderId(id) { + return ( + /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(String(id ?? "")) && + !RESERVED_CUSTOM_PROVIDER_IDS.includes(id) + ); +} + +/** The daemon's base-URL rule: HTTPS with a host and no userinfo, query, or + * fragment (`validProviderURL`). */ +export function validCustomProviderBaseURL(raw) { + let url; + try { + url = new URL(String(raw ?? "")); + } catch { + return false; + } + return ( + url.protocol === "https:" && + url.hostname !== "" && + !url.username && + !url.password && + !url.search && + !url.hash + ); +} + +/** Double-quotes a scalar for YAML (a JSON string is a valid YAML flow + * scalar), so model ids and URLs with `:` or spaces survive the paste. */ +const yamlQuote = (value) => JSON.stringify(String(value ?? "")); + +/** + * The operator settings.yaml `providers:` block for one custom provider — + * the exact shape mecated's strict parse accepts (ADR 0238). Non-secret by + * construction: the credential, if any, goes in auth.yaml via + * customProviderAuthSnippet. + */ +/** + * The settings.yaml provider_overrides block routing a BUILT-IN provider + * through a gateway/proxy base URL (ADR 0238's settings equivalent of the + * --*-base-url flags). Operator-tier only, like the providers: section. + * Returns "" when the URL fails the same validation custom gateways use. + */ +export function providerOverrideSnippet(name, baseURL) { + if (!name || !validCustomProviderBaseURL(baseURL)) return ""; + return `provider_overrides:\n ${name}:\n base_url: "${baseURL.trim()}"\n`; +} + +export function customProviderSettingsSnippet({ + id, + baseURL, + defaultModel, + apiFlavor, + authMethod, +}) { + return [ + "providers:", + ` ${id}:`, + ` base_url: ${yamlQuote(baseURL)}`, + ` default_model: ${yamlQuote(defaultModel)}`, + ` api_flavor: ${apiFlavor}`, + " auth:", + ` method: ${authMethod === "api_key" ? "api_key" : "none"}`, + "", + ].join("\n"); +} + +/** The auth.yaml key block for an api_key custom provider — a `` + * placeholder, never a real value (Studio rule 3), matching the built-in + * snippets above. */ +export function customProviderAuthSnippet(id) { + return `providers:\n ${id}:\n api_key: \n`; +} + +/** + * The one bounded key-probe URL for a custom provider, or "" when its flavor + * or base URL cannot be probed. Both OpenAI flavors and Anthropic Messages + * serve a models listing relative to the base URL, which is also what the + * daemon's own flavor-specific live lister calls — so a 200 here means the + * key works against the endpoint mecated will actually use. No credential + * enters or leaves this function: the controller attaches the stored key to + * the returned URL server-side. + */ +export function customProviderProbeURL(apiFlavor, baseURL) { + if (!validCustomProviderBaseURL(baseURL)) return ""; + const base = String(baseURL).replace(/\/+$/, ""); + switch (apiFlavor) { + case "openai-responses": + case "openai-chat-completions": + return `${base}/models`; + case "anthropic-messages": + return `${base}/models?limit=1`; + default: + return ""; + } +} + +/** + * The custom provider definitions in an operator settings.yaml text's + * top-level `providers:` section — ids and NON-secret shape only (the + * section never holds a credential; keys live in auth.yaml). A line scan, + * never a YAML parse, like everything else in this module. Entries whose id + * fails the daemon's grammar (or takes a reserved built-in name) are + * dropped, mirroring mecated's strict parse rejecting the whole section — + * listing a provider the daemon refused would lie. + * + * Returns NULL when the text has no top-level `providers:` key at all — + * distinct from an empty array — because mecated captures the section + * whole-block first-non-nil across its operator-tier files, and a caller + * folding several sources needs the same distinction. + * + * @param {string} text + * @returns {{name: string, baseURL: string, defaultModel: string, + * apiFlavor: string, authMethod: string}[] | null} + */ +export function listSettingsProviders(text) { + const lines = String(text ?? "").split("\n"); + const providersAt = lines.findIndex((line) => + /^providers:\s*(\{\s*\}\s*)?(#.*)?$/.test(line), + ); + if (providersAt === -1) return null; + const providers = []; + let current = null; + let inAuth = false; + for (const line of lines.slice(providersAt + 1)) { + if (/^\s*#/.test(line) || line.trim() === "") continue; + if (/^\S/.test(line)) break; // dedented past the providers block + const key = line.match(/^ {2}([A-Za-z0-9][A-Za-z0-9_-]*):\s*(#.*)?$/); + if (key) { + current = { + name: key[1], + baseURL: "", + defaultModel: "", + apiFlavor: "", + authMethod: "none", + }; + providers.push(current); + inAuth = false; + continue; + } + if (!current) continue; + const field = line.match(/^\s+([a-z_]+):\s*(.*)$/); + if (!field) continue; + const value = settingsScalar(field[2]); + switch (field[1]) { + case "auth": + inAuth = true; + break; + case "method": + if (inAuth) current.authMethod = value || "none"; + break; + case "base_url": + current.baseURL = value; + inAuth = false; + break; + case "default_model": + current.defaultModel = value; + inAuth = false; + break; + case "api_flavor": + current.apiFlavor = value; + inAuth = false; + break; + default: + break; + } + } + return providers.filter((provider) => validCustomProviderId(provider.name)); +} + +/** A settings scalar as a YAML reader would see it: trimmed, matched quotes + * stripped, a comment-only remainder read as "". */ +function settingsScalar(raw) { + let value = (raw ?? "").trim(); + if (value.startsWith("#")) return ""; + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { + value = value.slice(1, -1); + } + return value; +} + +const providersHeader = /^providers:\s*$/; +const providerKeyLine = /^ {2}([A-Za-z0-9_-]+):/; + +/** Strips matched surrounding quotes from a scalar, mirroring what a YAML + * reader would see. Returns "" for a comment-only remainder. */ +function scalarPresent(raw) { + let value = (raw ?? "").trim(); + if (value === "" || value.startsWith("#")) return false; + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { + value = value.slice(1, -1).trim(); + } + return value !== ""; +} + +/** + * Names + key-health BOOLEANS of the providers configured in an auth.yaml + * text — never their values. A provider "has a key" when its block carries a + * non-empty `api_key:` scalar or (openai-codex's shape) a non-empty + * `access_token:` anywhere in its nested block. + * + * @param {string} text + * @returns {{name: string, keyPresent: boolean}[]} + */ +export function listAuthFileProviders(text) { + const lines = String(text ?? "").split("\n"); + const providersAt = lines.findIndex((line) => providersHeader.test(line)); + if (providersAt === -1) return []; + const providers = []; + let current = null; + for (const line of lines.slice(providersAt + 1)) { + if (/^\s*#/.test(line) || line.trim() === "") continue; + const key = line.match(providerKeyLine); + if (key) { + current = { name: key[1], keyPresent: false }; + providers.push(current); + continue; + } + if (/^\S/.test(line)) break; // dedented past the providers block + if (!current) continue; // deeper content before any provider key: skip + const credential = line.match(/^\s+(?:api_key|access_token):(.*)$/); + if (credential && scalarPresent(credential[1])) current.keyPresent = true; + } + return providers; +} + +/** + * Removes the named provider's block from an auth.yaml text: the exact + * 2-space-indented `:` line inside the top-level `providers:` block + * plus every line that provably belongs to it (deeper-indented lines, + * including nested blocks and indented comments; blank lines only when more + * of the block follows them). Everything else is preserved byte-for-byte — + * comments at the providers level, sibling providers, unrelated top-level + * keys, trailing whitespace. The name match is exact (`===` on the captured + * key), so removing "openai" can never eat "openai-codex". + * + * The `providers:` header itself is left in place even when the last entry + * is removed — an empty `providers:` key is valid YAML the daemon reads as + * "no providers", and keeping it means the operator's file keeps its shape. + * + * @param {string} text + * @param {string} name + * @returns {{text: string, removed: boolean}} + */ +export function removeAuthFileProvider(text, name) { + const source = String(text ?? ""); + const lines = source.split("\n"); + const providersAt = lines.findIndex((line) => providersHeader.test(line)); + if (providersAt === -1) return { text: source, removed: false }; + + let start = -1; + for (let i = providersAt + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\S/.test(line)) break; // dedented past the providers block + const key = line.match(providerKeyLine); + if (key && key[1] === name) { + start = i; + break; + } + } + if (start === -1) return { text: source, removed: false }; + + // Walk the block: consume deeper-indented lines outright; consume a run of + // blank lines ONLY when a deeper-indented line follows it (a blank gap + // before the next sibling or a dedent stays in the file). A comment at the + // providers indent (or shallower) may document the NEXT entry, so it ends + // the block conservatively. + let end = start + 1; + while (end < lines.length) { + const line = lines[end]; + if (line.trim() === "") { + let ahead = end + 1; + while (ahead < lines.length && lines[ahead].trim() === "") ahead += 1; + if (ahead < lines.length && /^(?: {3,}|\t)/.test(lines[ahead])) { + end = ahead + 1; + continue; + } + break; + } + if (/^(?: {3,}|\t)/.test(line)) { + end += 1; + continue; + } + break; + } + return { + text: [...lines.slice(0, start), ...lines.slice(end)].join("\n"), + removed: true, + }; +} diff --git a/studio/src/lib/provider-auth.test.ts b/studio/src/lib/provider-auth.test.ts new file mode 100644 index 000000000..ec27b51b9 --- /dev/null +++ b/studio/src/lib/provider-auth.test.ts @@ -0,0 +1,463 @@ +import { describe, expect, it } from "vitest"; +import { + CUSTOM_PROVIDER_API_FLAVORS, + customProviderAuthSnippet, + customProviderProbeURL, + customProviderSettingsSnippet, + KNOWN_AUTH_PROVIDERS, + listAuthFileProviders, + listSettingsProviders, + RESERVED_CUSTOM_PROVIDER_IDS, + removeAuthFileProvider, + validCustomProviderBaseURL, + validCustomProviderId, + validProviderName, +} from "./provider-auth.mjs"; + +/** + * The auth.yaml surgery the controller performs is the ONE place Studio + * touches the credentials file, so its scope must be provable: removal cuts + * exactly the named provider's block, inventory reports booleans only, and a + * malformed or hostile file degrades to "not found" rather than a wider cut. + */ + +const full = [ + "# operator notes stay", + "providers:", + " openrouter:", + " api_key: sk-or-live", + " openai:", + ' api_key: "sk-oai"', + " openai-codex:", + " oauth:", + " access_token: tok", + " account_id: acct", + " expires_at: 2026-01-01T00:00:00Z", + " anthropic:", + " # a comment inside the block", + " api_key: sk-ant", + "other_top_level: true", + "", +].join("\n"); + +describe("listAuthFileProviders", () => { + it("lists every provider with a key-present boolean, never a value", () => { + const providers = listAuthFileProviders(full); + expect(providers).toEqual([ + { name: "openrouter", keyPresent: true }, + { name: "openai", keyPresent: true }, + { name: "openai-codex", keyPresent: true }, + { name: "anthropic", keyPresent: true }, + ]); + // Structural: no field of any row can carry the credential text. + expect(JSON.stringify(providers)).not.toMatch(/sk-|tok|acct/); + }); + + it("reports a missing, empty, quoted-empty, or commented key as absent", () => { + const text = [ + "providers:", + " a:", + " api_key:", + " b:", + ' api_key: ""', + " c:", + " api_key: # add me later", + " d:", + " base_url: https://example.test", + ].join("\n"); + expect(listAuthFileProviders(text)).toEqual([ + { name: "a", keyPresent: false }, + { name: "b", keyPresent: false }, + { name: "c", keyPresent: false }, + { name: "d", keyPresent: false }, + ]); + }); + + it("finds an oauth access_token at any nesting depth", () => { + const text = "providers:\n codex:\n oauth:\n access_token: t\n"; + expect(listAuthFileProviders(text)).toEqual([ + { name: "codex", keyPresent: true }, + ]); + }); + + it("stops at the first dedented top-level key", () => { + const text = + "providers:\n a:\n api_key: x\nnot_a_provider:\n b:\n api_key: y\n"; + expect(listAuthFileProviders(text).map((p) => p.name)).toEqual(["a"]); + }); + + it("returns [] for an empty file or one with no providers block", () => { + expect(listAuthFileProviders("")).toEqual([]); + expect(listAuthFileProviders("models:\n default: x\n")).toEqual([]); + }); +}); + +describe("removeAuthFileProvider", () => { + it("removes a middle provider's whole nested block and nothing else", () => { + const { text, removed } = removeAuthFileProvider(full, "openai-codex"); + expect(removed).toBe(true); + expect(text).toBe( + [ + "# operator notes stay", + "providers:", + " openrouter:", + " api_key: sk-or-live", + " openai:", + ' api_key: "sk-oai"', + " anthropic:", + " # a comment inside the block", + " api_key: sk-ant", + "other_top_level: true", + "", + ].join("\n"), + ); + }); + + it("never removes a longer-named sibling on a prefix match", () => { + // "openai" and "openai-codex" coexist; removing one must not touch the other. + const { text } = removeAuthFileProvider(full, "openai"); + expect(text).toContain(" openai-codex:"); + expect(text).toContain(" access_token: tok"); + expect(text).not.toContain(' api_key: "sk-oai"'); + expect(removeAuthFileProvider(text, "openai").removed).toBe(false); + }); + + it("removes the first and last providers cleanly", () => { + const first = removeAuthFileProvider(full, "openrouter"); + expect(first.text).not.toContain("sk-or-live"); + expect(first.text).toContain(" openai:"); + const last = removeAuthFileProvider(full, "anthropic"); + expect(last.text).not.toContain("sk-ant"); + // The comment inside the removed block goes with it. + expect(last.text).not.toContain("a comment inside the block"); + expect(last.text).toContain("other_top_level: true"); + }); + + it("keeps the providers: header when the last remaining provider is removed", () => { + const lone = "providers:\n openrouter:\n api_key: sk\n"; + const { text, removed } = removeAuthFileProvider(lone, "openrouter"); + expect(removed).toBe(true); + expect(text).toBe("providers:\n"); + }); + + it("consumes an internal blank line but keeps a trailing gap", () => { + const text = [ + "providers:", + " a:", + " api_key: x", + "", + " base_url: https://example.test", + "", + " b:", + " api_key: y", + ].join("\n"); + const { text: next } = removeAuthFileProvider(text, "a"); + expect(next).toBe(["providers:", "", " b:", " api_key: y"].join("\n")); + }); + + it("stops at a comment at the providers indent (it may document the next entry)", () => { + const text = [ + "providers:", + " a:", + " api_key: x", + " # b is the production key", + " b:", + " api_key: y", + ].join("\n"); + const { text: next } = removeAuthFileProvider(text, "a"); + expect(next).toBe( + [ + "providers:", + " # b is the production key", + " b:", + " api_key: y", + ].join("\n"), + ); + }); + + it("returns removed:false without touching the text when the name is absent", () => { + for (const missing of ["nope", "OPENAI", "openai ", "openai:"]) { + const { text, removed } = removeAuthFileProvider(full, missing); + expect(removed, missing).toBe(false); + expect(text, missing).toBe(full); + } + expect(removeAuthFileProvider("", "openai")).toEqual({ + text: "", + removed: false, + }); + }); + + it("only matches inside the providers block, never a lookalike elsewhere", () => { + const text = [ + "backups:", + " openai:", + " api_key: keep-me", + "providers:", + " openrouter:", + " api_key: sk", + ].join("\n"); + const { text: next, removed } = removeAuthFileProvider(text, "openai"); + expect(removed).toBe(false); + expect(next).toBe(text); + }); +}); + +describe("known provider registry", () => { + it("mirrors the daemon's built-in set and never embeds a real key", () => { + expect(KNOWN_AUTH_PROVIDERS.map((p) => p.name)).toEqual([ + "openrouter", + "anthropic", + "openai", + "opencode", + "openai-codex", + ]); + for (const provider of KNOWN_AUTH_PROVIDERS) { + expect(provider.snippet).toMatch(/^providers:\n {2}[a-z0-9-]+:\n/); + expect(provider.snippet).toMatch(/ { + for (const good of ["openai", "openai-codex", "a", "A_1"]) { + expect(validProviderName(good), good).toBe(true); + } + for (const bad of ["", "-lead", "a/b", "a b", "a".repeat(65), "../x"]) { + expect(validProviderName(bad), bad).toBe(false); + } + }); +}); + +/** + * Custom ("Custom gateway") provider helpers, ADR 0238: what the dialog + * validates and emits must match mecated's strict operator-settings parse + * (internal/adapter/permconfig/providers.go) byte-for-byte in shape, or the + * copied snippet fails the daemon's startup. + */ +describe("custom provider id and base URL", () => { + it("accepts the daemon's lower-case DNS-label-like grammar", () => { + for (const good of ["g", "my-gateway", "a1", `a${"b".repeat(61)}c`]) { + expect(validCustomProviderId(good), good).toBe(true); + } + }); + + it("rejects bad shapes and every reserved built-in id", () => { + for (const bad of [ + "", + "My-Gateway", + "-lead", + "trail-", + "under_score", + "a".repeat(64), + "a b", + ]) { + expect(validCustomProviderId(bad), bad).toBe(false); + } + // The daemon reserves the built-ins (reservedProviderIDs) — offering one + // would emit a snippet mecated refuses. + for (const reserved of RESERVED_CUSTOM_PROVIDER_IDS) { + expect(validCustomProviderId(reserved), reserved).toBe(false); + } + expect(RESERVED_CUSTOM_PROVIDER_IDS).toContain("openai"); + expect(RESERVED_CUSTOM_PROVIDER_IDS).toContain("toolhive"); + }); + + it("requires HTTPS with no userinfo, query, or fragment", () => { + expect(validCustomProviderBaseURL("https://gw.example/v1")).toBe(true); + expect(validCustomProviderBaseURL("https://gw.example:8443")).toBe(true); + for (const bad of [ + "http://gw.example/v1", + "https://user:pass@gw.example", + "https://gw.example/v1?token=x", + "https://gw.example/v1#frag", + "not a url", + "", + ]) { + expect(validCustomProviderBaseURL(bad), bad).toBe(false); + } + }); +}); + +describe("custom provider snippets", () => { + it("emits the exact settings providers: block the daemon parses", () => { + const snippet = customProviderSettingsSnippet({ + id: "my-gateway", + baseURL: "https://gw.example/v1", + defaultModel: "org/model:free", + apiFlavor: "openai-responses", + authMethod: "api_key", + }); + expect(snippet).toBe( + [ + "providers:", + " my-gateway:", + ' base_url: "https://gw.example/v1"', + ' default_model: "org/model:free"', + " api_flavor: openai-responses", + " auth:", + " method: api_key", + "", + ].join("\n"), + ); + }); + + it("defaults any non-api_key auth to the daemon's none", () => { + const snippet = customProviderSettingsSnippet({ + id: "open-gw", + baseURL: "https://gw.example", + defaultModel: "m", + apiFlavor: "anthropic-messages", + authMethod: "none", + }); + expect(snippet).toContain(" method: none"); + expect(snippet).not.toContain("api_key"); + }); + + it("emits an auth.yaml key block with a placeholder, never a value", () => { + expect(customProviderAuthSnippet("my-gateway")).toBe( + "providers:\n my-gateway:\n api_key: \n", + ); + }); + + it("round-trips: the emitted settings snippet lists back verbatim", () => { + const snippet = customProviderSettingsSnippet({ + id: "round-trip", + baseURL: "https://gw.example/v1", + defaultModel: "m1", + apiFlavor: "openai-chat-completions", + authMethod: "none", + }); + expect(listSettingsProviders(snippet)).toEqual([ + { + name: "round-trip", + baseURL: "https://gw.example/v1", + defaultModel: "m1", + apiFlavor: "openai-chat-completions", + authMethod: "none", + }, + ]); + }); +}); + +describe("listSettingsProviders", () => { + const settings = [ + "# operator settings", + "models:", + " default: x", + "providers:", + " keyed-gw:", + ' base_url: "https://keyed.example/v1"', + " default_model: m-keyed", + " api_flavor: openai-responses", + " auth:", + " method: api_key", + " open-gw:", + " base_url: https://open.example", + " default_model: m-open", + " api_flavor: anthropic-messages", + " auth:", + " method: none", + " implicit-gw:", + " base_url: https://implicit.example", + " default_model: m-implicit", + " api_flavor: openai-chat-completions", + "permissions:", + " allow: []", + "", + ].join("\n"); + + it("lists every custom definition with its non-secret shape", () => { + expect(listSettingsProviders(settings)).toEqual([ + { + name: "keyed-gw", + baseURL: "https://keyed.example/v1", + defaultModel: "m-keyed", + apiFlavor: "openai-responses", + authMethod: "api_key", + }, + { + name: "open-gw", + baseURL: "https://open.example", + defaultModel: "m-open", + apiFlavor: "anthropic-messages", + authMethod: "none", + }, + { + // No auth block = the daemon's default, none. + name: "implicit-gw", + baseURL: "https://implicit.example", + defaultModel: "m-implicit", + apiFlavor: "openai-chat-completions", + authMethod: "none", + }, + ]); + }); + + it("returns null when the text has no providers: key (the capture fold)", () => { + // Distinct from []: mecated captures the section whole-block + // first-non-nil across operator files, so a caller folding an imported + // operator-settings.yaml over the user-global one needs the difference. + expect(listSettingsProviders("models:\n default: x\n")).toBeNull(); + expect(listSettingsProviders("")).toBeNull(); + expect(listSettingsProviders("providers: {}\n")).toEqual([]); + }); + + it("drops entries whose id the daemon would refuse", () => { + const text = [ + "providers:", + " Bad_Name:", + " base_url: https://x.example", + " openai:", // reserved built-in + " base_url: https://y.example", + " fine:", + " base_url: https://z.example", + ].join("\n"); + expect(listSettingsProviders(text)?.map((p) => p.name)).toEqual(["fine"]); + }); + + it("stops at the first dedented top-level key", () => { + const text = + "providers:\n a-gw:\n base_url: https://a.example\nother:\n b-gw:\n base_url: https://b.example\n"; + expect(listSettingsProviders(text)?.map((p) => p.name)).toEqual(["a-gw"]); + }); +}); + +describe("customProviderProbeURL", () => { + it("builds a models-list probe per flavor against the configured base", () => { + expect( + customProviderProbeURL("openai-responses", "https://gw.example/v1"), + ).toBe("https://gw.example/v1/models"); + expect( + customProviderProbeURL( + "openai-chat-completions", + "https://gw.example/v1/", + ), + ).toBe("https://gw.example/v1/models"); + expect( + customProviderProbeURL("anthropic-messages", "https://gw.example"), + ).toBe("https://gw.example/models?limit=1"); + }); + + it("refuses unknown flavors and unprobeable base URLs", () => { + expect(customProviderProbeURL("grpc-exotic", "https://gw.example")).toBe( + "", + ); + expect( + customProviderProbeURL("openai-responses", "http://gw.example"), + ).toBe(""); + expect(customProviderProbeURL("openai-responses", "")).toBe(""); + }); + + it("covers exactly the daemon's closed api_flavor enum", () => { + expect(CUSTOM_PROVIDER_API_FLAVORS).toEqual([ + "openai-responses", + "openai-chat-completions", + "anthropic-messages", + ]); + for (const flavor of CUSTOM_PROVIDER_API_FLAVORS) { + expect( + customProviderProbeURL(flavor, "https://gw.example"), + flavor, + ).not.toBe(""); + } + }); +}); diff --git a/studio/src/lib/request-trust.test.ts b/studio/src/lib/request-trust.test.ts new file mode 100644 index 000000000..501fa3d67 --- /dev/null +++ b/studio/src/lib/request-trust.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + requestIsTrusted, + studioAllowedOrigins, + type TrustCheckedRequest, +} from "./request-trust"; + +/** Plain-object requests: undici's Request drops forbidden headers like + * `host`, which would make the rebinding rows vacuous. */ +function fakeRequest( + url: string, + headers: Record = {}, +): TrustCheckedRequest { + const map = new Map( + Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]), + ); + return { + url, + headers: { get: (name) => map.get(name.toLowerCase()) ?? null }, + }; +} + +describe("studioAllowedOrigins", () => { + it("defaults to the local dev origins", () => { + // "" (unset) exercises the default without reading this process's env. + expect(studioAllowedOrigins("")).toEqual( + new Set(["http://localhost:3000", "http://127.0.0.1:3000"]), + ); + }); + + it("parses a configured comma list, trimming trailing slashes", () => { + expect( + studioAllowedOrigins("https://studio.example/, http://localhost:3000"), + ).toEqual(new Set(["https://studio.example", "http://localhost:3000"])); + }); +}); + +/** The CSRF/DNS-rebinding truth table, mirroring the hermetic suite's idiom + * (tests/rendered-html.test.mjs) for the shared server-tier check the proxy + * routes run. */ +describe("requestIsTrusted", () => { + const allowed = new Set(["http://localhost:3000"]); + const table: Array<{ + name: string; + request: TrustCheckedRequest; + trusted: boolean; + }> = [ + { + name: "same-origin navigation with no Origin header", + request: fakeRequest("http://localhost:3000/api/mecatl-control/status", { + host: "localhost:3000", + }), + trusted: true, + }, + { + name: "same-origin fetch with a matching Origin", + request: fakeRequest("http://localhost:3000/api/mecatl/v1/sessions", { + host: "localhost:3000", + origin: "http://localhost:3000", + }), + trusted: true, + }, + { + name: "cross-site request (CSRF): hostile Origin", + request: fakeRequest("http://localhost:3000/api/mecatl/v1/sessions", { + host: "localhost:3000", + origin: "https://evil.example", + }), + trusted: false, + }, + { + name: "DNS rebinding: hostile Host header", + request: fakeRequest("http://localhost:3000/api/mecatl/v1/models", { + host: "attacker.example", + }), + trusted: false, + }, + { + name: "host absent falls back to the request URL's host", + request: fakeRequest("http://localhost:3000/api/mecatl-control/status"), + trusted: true, + }, + ]; + + for (const row of table) { + it(row.name, () => { + expect(requestIsTrusted(row.request, allowed)).toBe(row.trusted); + }); + } + + it("honors x-forwarded-proto when a TLS terminator fronts Studio", () => { + const https = new Set(["https://studio.example"]); + expect( + requestIsTrusted( + fakeRequest("http://studio.example/api/mecatl/v1/models", { + host: "studio.example", + "x-forwarded-proto": "https", + }), + https, + ), + ).toBe(true); + expect( + requestIsTrusted( + fakeRequest("http://studio.example/api/mecatl/v1/models", { + host: "studio.example", + "x-forwarded-proto": "http", + }), + https, + ), + ).toBe(false); + }); +}); diff --git a/studio/src/lib/request-trust.ts b/studio/src/lib/request-trust.ts new file mode 100644 index 000000000..ed43beac9 --- /dev/null +++ b/studio/src/lib/request-trust.ts @@ -0,0 +1,50 @@ +/** + * Origin trust for the Next server tier's API routes (CLAUDE.md rule 4's + * browser-facing half): a request is served only when it arrived at an + * allowlisted Studio origin (Host — the DNS-rebinding check) AND, when the + * browser attached one, from an allowlisted Origin (the CSRF check). + * + * Extracted from `server-proxy.ts` so the OIDC auth routes share the exact + * same table instead of growing a second, drifting copy — and so the check is + * testable without importing a `server-only` module. + */ + +/** The structural slice of `Request` the check reads — lets tests use plain + * objects (undici's `Request` silently drops forbidden headers like `host`, + * which would make a rebinding test vacuous). */ +export type TrustCheckedRequest = { + url: string; + headers: { get(name: string): string | null }; +}; + +export function studioAllowedOrigins( + configured = process.env.MECATL_STUDIO_PUBLIC_ORIGIN, +): Set { + return new Set( + (configured || "http://localhost:3000,http://127.0.0.1:3000") + .split(",") + .map((origin) => origin.trim().replace(/\/$/, "")) + .filter(Boolean), + ); +} + +export function requestIsTrusted( + request: TrustCheckedRequest, + allowed: Set = studioAllowedOrigins(), +): boolean { + const requestURL = new URL(request.url); + const host = request.headers.get("host") || requestURL.host; + const forwardedProtocol = request.headers + .get("x-forwarded-proto") + ?.split(",", 1)[0] + ?.trim(); + const protocol = + forwardedProtocol === "https" || forwardedProtocol === "http" + ? `${forwardedProtocol}:` + : requestURL.protocol; + const requestOrigin = `${protocol}//${host}`; + const browserOrigin = request.headers.get("origin"); + return ( + allowed.has(requestOrigin) && (!browserOrigin || allowed.has(browserOrigin)) + ); +} diff --git a/studio/src/lib/server-proxy.ts b/studio/src/lib/server-proxy.ts new file mode 100644 index 000000000..f542ce652 --- /dev/null +++ b/studio/src/lib/server-proxy.ts @@ -0,0 +1,221 @@ +import "server-only"; + +import { requestIsTrusted } from "@/lib/request-trust"; + +const controllerBaseURL = "http://127.0.0.1:8788"; +const forwardedRequestHeaders = [ + "accept", + "content-type", + "last-event-id", + "mcp-protocol-version", + "mcp-session-id", +]; +const forwardedResponseHeaders = [ + "cache-control", + "content-type", + "mcp-session-id", + "www-authenticate", +]; +const externalBaseURL = () => + process.env.MECATL_BASE_URL?.trim().replace(/\/$/, "") || ""; + +function forbidden() { + return Response.json( + { error: "request origin is not allowed" }, + { status: 403 }, + ); +} + +function copyRequestHeaders(request: Request) { + const headers = new Headers(); + for (const name of forwardedRequestHeaders) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + return headers; +} + +function copyResponse(upstream: Response) { + const headers = new Headers(); + for (const name of forwardedResponseHeaders) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return new Response(upstream.body, { status: upstream.status, headers }); +} + +async function forward( + request: Request, + target: URL, + headers: Headers, + bodyOverride?: BodyInit, +) { + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + try { + const upstream = await fetch(target, { + method: request.method, + headers, + body: hasBody + ? (bodyOverride ?? (await request.arrayBuffer())) + : undefined, + cache: "no-store", + redirect: "manual", + }); + return copyResponse(upstream); + } catch { + return Response.json( + { error: "The Mecatl service is unavailable. It may be restarting." }, + { status: 503 }, + ); + } +} + +// Session and team creation require a workspace — the directory every file and +// shell tool is rooted at. It is resolved server-side (managed: from the +// controller's /status; external: from MECATL_WORKSPACE) so a machine-specific +// absolute path never reaches the client bundle, and so the browser can never +// choose it. +// +// SERVER-ASSIGNED deployments (ADR 0237): a daemon with a network-facing +// listener assigns the workspace itself and REJECTS any non-empty client +// workspace with 400 "deployment assigns the workspace". Against such a +// daemon, leave MECATL_WORKSPACE unset in external mode — an unset value +// deliberately injects nothing, which is the correct empty-workspace create. +const workspaceInjectionPaths = new Set([ + "v1/sessions", + "v1/teams", + "v1/schedules", +]); + +async function resolveWorkspace(external: string): Promise { + if (external) return process.env.MECATL_WORKSPACE?.trim() || ""; + try { + const response = await fetch(`${controllerBaseURL}/status`, { + cache: "no-store", + signal: AbortSignal.timeout(3_000), + }); + if (!response.ok) return ""; + const status = (await response.json()) as { workspace?: unknown }; + return typeof status.workspace === "string" ? status.workspace : ""; + } catch { + return ""; + } +} + +async function withWorkspace( + request: Request, + external: string, +): Promise { + try { + const raw = await request.text(); + const body = raw ? (JSON.parse(raw) as Record) : {}; + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return raw; + } + if (!body.workspace) { + const workspace = await resolveWorkspace(external); + if (workspace) body.workspace = workspace; + } + return JSON.stringify(body); + } catch { + // Not JSON: forward untouched and let the daemon reject it. + return undefined; + } +} + +export async function proxyMecatl(request: Request, path: string[]) { + if (!requestIsTrusted(request)) return forbidden(); + const external = externalBaseURL(); + const base = external || `${controllerBaseURL}/mecatl`; + const target = new URL(`${base}/${path.map(encodeURIComponent).join("/")}`); + target.search = new URL(request.url).search; + const headers = copyRequestHeaders(request); + if (external) { + const token = process.env.MECATL_AUTH_TOKEN?.trim(); + if (token) + headers.set( + "authorization", + `Bearer ${token.replace(/^Bearer\s+/i, "")}`, + ); + } else { + headers.set("x-mecatl-studio-request", "1"); + } + + let bodyOverride: BodyInit | undefined; + let injectedWorkspace = false; + if ( + request.method === "POST" && + workspaceInjectionPaths.has(path.join("/")) + ) { + bodyOverride = await withWorkspace(request, external); + if (typeof bodyOverride === "string") { + headers.set("content-type", "application/json"); + injectedWorkspace = bodyOverride.includes('"workspace"'); + } + } + // Slash-command discovery scans the workspace's command directories; the + // workspace is a query parameter there, injected here for the same reason + // it is injected into session bodies. + if ( + request.method === "GET" && + path.join("/") === "v1/commands" && + !target.searchParams.get("workspace") + ) { + const workspace = await resolveWorkspace(external); + if (workspace) target.searchParams.set("workspace", workspace); + } + const response = await forward(request, target, headers, bodyOverride); + // A server-assigned deployment refusing OUR injected workspace is a + // configuration problem this tier created — name the fix instead of + // relaying a bare 400 the user cannot act on (ADR 0237). + if (injectedWorkspace && response.status === 400) { + try { + const clone = response.clone(); + const body = (await clone.json()) as { error?: string }; + if (body.error?.includes("deployment assigns the workspace")) { + return Response.json( + { + ...body, + error: `${body.error} — this deployment assigns its own workspace: unset MECATL_WORKSPACE in Studio's environment so creates are sent workspace-free.`, + }, + { status: 400 }, + ); + } + } catch { + // Not JSON — relay untouched. + } + } + return response; +} + +export async function proxyControl(request: Request, path: string[]) { + if (!requestIsTrusted(request)) return forbidden(); + const external = externalBaseURL(); + if (external) { + if (request.method === "GET" && path.join("/") === "status") { + return Response.json({ + mode: "external", + provider: "external daemon", + running: true, + workspace: process.env.MECATL_WORKSPACE?.trim() || "", + gateway: null, + modelRouter: null, + operatorSettings: true, + skills: null, + memory: null, + }); + } + return Response.json( + { error: "This setting is owned by the external mecated deployment." }, + { status: 409 }, + ); + } + + const target = new URL( + `${controllerBaseURL}/${path.map(encodeURIComponent).join("/")}`, + ); + target.search = new URL(request.url).search; + const headers = copyRequestHeaders(request); + headers.set("x-mecatl-studio-request", "1"); + return forward(request, target, headers); +} diff --git a/studio/tests/rendered-html.test.mjs b/studio/tests/rendered-html.test.mjs new file mode 100644 index 000000000..293184b01 --- /dev/null +++ b/studio/tests/rendered-html.test.mjs @@ -0,0 +1,326 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import http from "node:http"; +import { dirname, resolve } from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + requestIsAllowed, + validateGatewayURL, +} from "../src/lib/controller-security.mjs"; + +// Hermetic server-tier suite: a real `next start` of the production build in +// EXTERNAL mode, against a fake in-process daemon that records every request. +// This is the only layer that proves the proxy tier's behavior (bearer +// injection, CSRF 403, external-mode 409, workspace injection, offline 503) +// end to end. Wire decoders are covered by the vitest suite in +// src/lib/protocol. Requires `npm run build` first (npm test does that). + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const nextBin = resolve(root, "node_modules/.bin/next"); +const upstreamRequests = []; +let upstream; +let studio; +let studioBaseURL; +let offlineStudio; +let offlineBaseURL; + +async function listen(server) { + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + return server.address().port; +} + +async function freePort() { + const probe = http.createServer(); + const port = await listen(probe); + await new Promise((resolveClose) => probe.close(resolveClose)); + return port; +} + +async function waitForServer(url, child) { + for (let attempt = 0; attempt < 150; attempt += 1) { + if (child.exitCode !== null) + throw new Error(`Next exited during test startup (${child.exitCode})`); + try { + // Any HTTP answer means Next is up (the root renders the branded + // not-found page — a 404 — until the chat route lands in the stack). + const response = await fetch(url); + if (response.status) return; + } catch { + /* still starting */ + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("Next did not become ready for tests"); +} + +function startStudio(port, baseURL, mecatlBaseURL) { + return spawn(nextBin, ["start", "-p", String(port)], { + cwd: root, + env: { + ...process.env, + MECATL_BASE_URL: mecatlBaseURL, + MECATL_AUTH_TOKEN: "test-secret", + MECATL_WORKSPACE: "/workspace/from-deployment", + MECATL_STUDIO_PUBLIC_ORIGIN: baseURL, + }, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +before(async () => { + upstream = http.createServer((request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + upstreamRequests.push({ + url: request.url, + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString() || null, + }); + response.setHeader("Content-Type", "application/json"); + if (request.url === "/v1/sessions") { + response.end(JSON.stringify({ session_id: "session-from-upstream" })); + return; + } + response.end( + JSON.stringify({ models: [{ id: "test-model", provider_id: "test" }] }), + ); + }); + }); + const upstreamPort = await listen(upstream); + + const studioPort = await freePort(); + studioBaseURL = `http://127.0.0.1:${studioPort}`; + studio = startStudio( + studioPort, + studioBaseURL, + `http://127.0.0.1:${upstreamPort}`, + ); + + // A second instance whose daemon does not exist: the offline deployment. + const offlinePort = await freePort(); + const deadPort = await freePort(); + offlineBaseURL = `http://127.0.0.1:${offlinePort}`; + offlineStudio = startStudio( + offlinePort, + offlineBaseURL, + `http://127.0.0.1:${deadPort}`, + ); + + await Promise.all([ + waitForServer(studioBaseURL, studio), + waitForServer(offlineBaseURL, offlineStudio), + ]); +}); + +after(async () => { + studio?.kill("SIGTERM"); + offlineStudio?.kill("SIGTERM"); + await new Promise((resolveClose) => upstream.close(resolveClose)); +}); + +test("server-renders Mecatl Studio", async () => { + // No status assertion: the root redirects into the branded not-found page + // until the chat route lands in the stack — the SSR proof is the body. + const response = await fetch(`${studioBaseURL}/`); + assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i); + const html = await response.text(); + assert.match(html, /Mecatl Studio/); +}); + +test("external mode injects daemon auth server-side and disables local controls", async () => { + const models = await fetch(`${studioBaseURL}/api/mecatl/v1/models`); + assert.equal(models.status, 200); + assert.deepEqual(await models.json(), { + models: [{ id: "test-model", provider_id: "test" }], + }); + assert.deepEqual(upstreamRequests.at(-1), { + url: "/v1/models", + authorization: "Bearer test-secret", + body: null, + }); + + const status = await fetch(`${studioBaseURL}/api/mecatl-control/status`).then( + (response) => response.json(), + ); + assert.equal(status.mode, "external"); + assert.equal(status.workspace, "/workspace/from-deployment"); + + const mutation = await fetch( + `${studioBaseURL}/api/mecatl-control/model-router`, + { method: "POST" }, + ); + assert.equal(mutation.status, 409); + assert.match((await mutation.json()).error, /external mecated deployment/); + + // Skill AND provider management are controller-owned: external mode owns + // nothing locally, so every write — and even the inventory reads — answers + // 409. The provider rows pin that no external deployment's auth.yaml can + // be probed, removed, or even enumerated through Studio. + for (const [path, method] of [ + ["skills", "POST"], + ["skills/pr-feedback/disable", "POST"], + ["skills/pr-feedback/enable", "POST"], + ["skills/pr-feedback/body", "PUT"], + ["skills/pr-feedback", "DELETE"], + ["skills/disabled", "GET"], + ["providers", "GET"], + ["providers/known", "GET"], + ["providers/openrouter/test", "POST"], + ["providers/openrouter", "DELETE"], + ["restart", "POST"], + ]) { + const refused = await fetch(`${studioBaseURL}/api/mecatl-control/${path}`, { + method, + }); + assert.equal(refused.status, 409, `${method} ${path}`); + } + + const csrf = await fetch(`${studioBaseURL}/api/mecatl/v1/sessions`, { + method: "POST", + headers: { origin: "https://evil.example", "content-type": "text/plain" }, + body: "{}", + }); + assert.equal(csrf.status, 403); +}); + +test("session creation carries the deployment workspace, injected server-side", async () => { + const created = await fetch(`${studioBaseURL}/api/mecatl/v1/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "default" }), + }); + assert.equal(created.status, 200); + const recorded = upstreamRequests.at(-1); + assert.equal(recorded.url, "/v1/sessions"); + assert.equal(recorded.authorization, "Bearer test-secret"); + // The browser sent no workspace; the proxy resolved it from the deployment + // env, so a machine path never reaches (or comes from) the client. + assert.deepEqual(JSON.parse(recorded.body), { + mode: "default", + workspace: "/workspace/from-deployment", + }); +}); + +test("an unreachable daemon is a friendly 503, never demo content", async () => { + const models = await fetch(`${offlineBaseURL}/api/mecatl/v1/models`); + assert.equal(models.status, 503); + assert.match((await models.json()).error, /unavailable/i); + + // The page itself still serves — the offline state is the UI's to render — + // and carries no fabricated inventory. + const page = await fetch(`${offlineBaseURL}/`); + assert.match(await page.text(), /Mecatl Studio/); +}); + +test("controller policy rejects CSRF and DNS-rebinding requests", () => { + const policy = { + allowedOrigins: new Set(["http://localhost:3000"]), + mcpProxyPrefix: "/mcp-proxy/unguessable/", + }; + const url = new URL("http://127.0.0.1:8788/mcp"); + assert.equal( + requestIsAllowed( + { method: "POST", headers: { host: "127.0.0.1:8788" } }, + url, + policy, + ), + false, + ); + assert.equal( + requestIsAllowed( + { + method: "POST", + headers: { host: "127.0.0.1:8788", origin: "https://evil.example" }, + }, + url, + policy, + ), + false, + ); + assert.equal( + requestIsAllowed( + { + method: "POST", + headers: { host: "attacker.example", "x-mecatl-studio-request": "1" }, + }, + url, + policy, + ), + false, + ); + assert.equal( + requestIsAllowed( + { + method: "POST", + headers: { + host: "127.0.0.1:8788", + origin: "http://localhost:3000", + "x-mecatl-studio-request": "1", + }, + }, + url, + policy, + ), + true, + ); + // Skill and provider routes are NOT in the header-free read-only + // allowlist: even the inventory GETs need the server-set studio header, + // and a mutation without it is refused like any other controller write. + // For /providers that gate is part of rule 3's perimeter — a page in + // another loopback-origin app must not be able to enumerate auth.yaml's + // provider names, key-test a stored credential, or delete a block. + for (const [method, pathname] of [ + ["GET", "/skills/disabled"], + ["POST", "/skills"], + ["POST", "/skills/pr-feedback/disable"], + ["DELETE", "/skills/pr-feedback"], + ["GET", "/providers"], + ["GET", "/providers/known"], + ["POST", "/providers/openrouter/test"], + ["DELETE", "/providers/openrouter"], + ["POST", "/restart"], + ]) { + assert.equal( + requestIsAllowed( + { + method, + headers: { host: "127.0.0.1:8788", origin: "http://localhost:3000" }, + }, + new URL(`http://127.0.0.1:8788${pathname}`), + policy, + ), + false, + `${method} ${pathname}`, + ); + } +}); + +test("gateway egress requires HTTPS or an operator-enabled loopback exception", () => { + assert.equal( + validateGatewayURL("https://gateway.example/mcp").protocol, + "https:", + ); + assert.throws( + () => validateGatewayURL("http://169.254.169.254/latest/meta-data"), + /must use HTTPS/, + ); + assert.throws( + () => validateGatewayURL("http://127.0.0.1:9000/mcp"), + /must use HTTPS/, + ); + assert.equal( + validateGatewayURL("http://127.0.0.1:9000/mcp", { allowLoopbackHTTP: true }) + .hostname, + "127.0.0.1", + ); + assert.throws( + () => validateGatewayURL("https://user:secret@gateway.example/mcp"), + /must not contain credentials/, + ); +}); diff --git a/user-docs/building/what-you-get/studio.md b/user-docs/building/what-you-get/studio.md index a21308c46..abc5d1438 100644 --- a/user-docs/building/what-you-get/studio.md +++ b/user-docs/building/what-you-get/studio.md @@ -12,10 +12,38 @@ Studio reads and writes the daemon's state rather than keeping its own. :::note Landing in progress Studio is landing as a stacked series of pull requests. This page grows with -each one; right now the module foundation (toolchain, CI, and the shared UI -kit) is in the tree, and the surfaces arrive next. +each one; right now the module foundation and the server tier (proxy + +managed-mode controller) are in the tree, and the surfaces arrive next. ::: +## Starting it + +Managed mode (the default) supervises a `mecated` from your checkout: + +```sh +task build # produces bin/mecated +task studio:dev # controller + web server; open http://localhost:3000 +``` + +The controller spawns `mecated` on a random loopback port with a generated +bearer token, resolves the workspace to the repo root, and restarts the daemon +when you change its configuration. `task studio:stop` tears everything down. + +External mode points Studio at a daemon you run elsewhere: + +```sh +MECATL_BASE_URL=https://mecated.internal:8081 \ +MECATL_AUTH_TOKEN=... \ +MECATL_WORKSPACE=/srv/workspace \ +npm run start +``` + +In external mode there is no local controller: every local control surface +answers 409 as owned by the deployment. + Studio is **daemon-only** by design: when the daemon is unreachable it renders -an offline state that names the fix — never simulated content. The decision -record is ADR 0288 (`docs/adr/0288-studio-atrium-module.md` in the repo). +an offline state that names the fix — never simulated content. The browser +never holds a daemon address or credential; Studio's own server tier pins +Host/Origin, injects the bearer and the session workspace server-side, and +allowlists headers in both directions. The decision record is ADR 0288 +(`docs/adr/0288-studio-atrium-module.md` in the repo).