Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/PRODUCTION-READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do
| mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) |
| ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) |
| Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ⛔ external-mode OIDC sign-in · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ⛔ advanced chat tiers (attachments, tool drill-down, steer/queue, threads, re-attach, modes, mobile) · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — |

## Security
Expand Down
10 changes: 10 additions & 0 deletions studio/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
# required).
# MECATL_ALLOW_INSECURE_LOOPBACK_MCP=

# ── External-mode OIDC sign-in (optional) ────────────────────────────────────
# When the external daemon sits behind an OIDC-aware gateway, Studio's server
# tier can run the sign-in itself (PKCE; tokens held in server process memory
# only). Unset = the static MECATL_AUTH_TOKEN path above.
# MECATL_OIDC_ISSUER=
# MECATL_OIDC_CLIENT_ID=
# MECATL_OIDC_REDIRECT_URI=
# MECATL_OIDC_SCOPE=
# MECATL_OIDC_AUDIENCE=

# ── Branding (optional) ──────────────────────────────────────────────────────
# Display name used as the logo's alt text.
# BRAND_NAME=
Expand Down
102 changes: 102 additions & 0 deletions studio/src/app/api/auth/oidc/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Remote OIDC login routes (requirement H3) — the server-tier half of
* Authorization Code + PKCE against an OIDC-protected external mecated.
* Tokens live in `src/lib/oidc-session.ts` (process memory) and never reach
* the browser (rule 3). Same origin-trust table as the proxy routes (rule 4's
* browser-facing half).
*
* GET /api/auth/oidc/status → sign-in state for the settings card
* GET /api/auth/oidc/start → 302 to the issuer's authorize URL
* (opened by the card's Sign in button —
* never without that user action, H3.3)
* GET /api/auth/oidc/callback → the registered redirect URI; verifies
* state + PKCE server-side, exchanges the
* code, renders a close-this-tab page
* POST /api/auth/oidc/logout → local sign-out + best-effort revocation
*/
import {
beginOidcLogin,
type CallbackOutcome,
completeOidcCallback,
oidcLoginStatus,
oidcSignOut,
} from "@/lib/oidc-session";
import { requestIsTrusted } from "@/lib/request-trust";

type Context = { params: Promise<{ action: string }> };

const noStore = { "cache-control": "no-store" };

const forbidden = () =>
Response.json({ error: "request origin is not allowed" }, { status: 403 });

const unknownAction = () =>
Response.json({ error: "unknown auth action" }, { status: 404 });

export async function GET(request: Request, context: Context) {
if (!requestIsTrusted(request)) return forbidden();
const { action } = await context.params;
if (action === "status") {
return Response.json(oidcLoginStatus(), { headers: noStore });
}
if (action === "start") {
try {
const { authorizationUrl } = await beginOidcLogin();
return new Response(null, {
status: 302,
headers: { ...noStore, location: authorizationUrl },
});
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: "Could not start OIDC sign-in.";
return Response.json(
{ error: message },
{ status: 400, headers: noStore },
);
}
}
if (action === "callback") {
const outcome = await completeOidcCallback(
new URL(request.url).searchParams,
);
return callbackPage(outcome);
}
return unknownAction();
}

export async function POST(request: Request, context: Context) {
if (!requestIsTrusted(request)) return forbidden();
const { action } = await context.params;
if (action === "logout") {
return Response.json(await oidcSignOut(), { headers: noStore });
}
return unknownAction();
}

/** Mirrors the controller's gateway-OAuth callback page: a tiny same-origin
* HTML page that notifies the opener (the settings card listens for the
* `mecatl-oidc` message) and asks to be closed. The failure message is
* harness-authored or provider-bounded upstream, and escaped here anyway. */
function callbackPage(outcome: CallbackOutcome): Response {
const headers = {
"content-type": "text/html; charset=utf-8",
// The authorization code rides this page's URL; keep it out of referrers.
"referrer-policy": "no-referrer",
...noStore,
};
const notify = (ok: boolean) =>
`<script>window.opener?.postMessage({type:"mecatl-oidc",ok:${ok ? "true" : "false"}},window.location.origin);setTimeout(()=>window.close(),700)</script>`;
if (outcome.ok) {
return new Response(
`<!doctype html><title>Signed in to Mecatl</title><style>body{font:16px system-ui;padding:40px;color:#25231f}</style><h1>Signed in</h1><p>You can close this tab and return to Mecatl Studio.</p>${notify(true)}`,
{ headers },
);
}
const message = outcome.error.replace(/[<>&"']/g, "");
return new Response(
`<!doctype html><title>Mecatl sign-in failed</title><style>body{font:16px system-ui;padding:40px;color:#25231f}</style><h1>Could not sign in</h1><p>${message}</p>${notify(false)}`,
{ status: 400, headers },
);
}
150 changes: 150 additions & 0 deletions studio/src/app/workspace/settings/_components/oidc-login-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"use client";

/**
* Remote sign-in card for OIDC-protected external deployments (requirement
* H3). Self-contained and NOT yet mounted anywhere — the natural home is the
* Settings runtime/connection section (beside the connection status), mounted
* only when the deployment mode is external; the orchestrator wires it in
* during integration.
*
* Talks only to the server-tier auth routes (`/api/auth/oidc/*`); no token
* ever reaches this component (rule 3). Sign in opens the authorize redirect
* in a popup — the explicit user action H3.3 requires — and the card refreshes
* on the callback page's `mecatl-oidc` message and on window focus.
*/
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Note, SettingsCard, SettingsRow } from "./settings-card";

type OidcStatus = {
configured: boolean;
state: "not-configured" | "signed-out" | "signed-in" | "expired";
problem?: string;
issuer?: string;
subject?: string;
email?: string;
expiresAt?: string;
};

export function OidcLoginCard() {
const [status, setStatus] = useState<OidcStatus | null>(null);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);

const refresh = useCallback(async () => {
try {
const response = await fetch("/api/auth/oidc/status", {
cache: "no-store",
});
if (!response.ok) throw new Error();
setStatus((await response.json()) as OidcStatus);
setFailed(false);
} catch {
setStatus(null);
setFailed(true);
}
}, []);

useEffect(() => {
void refresh();
const onMessage = (event: MessageEvent) => {
const data = event.data as { type?: unknown } | null;
if (
event.origin === window.location.origin &&
data?.type === "mecatl-oidc"
) {
void refresh();
}
};
const onFocus = () => void refresh();
window.addEventListener("message", onMessage);
window.addEventListener("focus", onFocus);
return () => {
window.removeEventListener("message", onMessage);
window.removeEventListener("focus", onFocus);
};
}, [refresh]);

const signIn = () => {
// The route 302s straight to the issuer's authorize URL; the callback
// page notifies this card and closes itself.
window.open(
"/api/auth/oidc/start",
"mecatl-oidc-login",
"width=520,height=680",
);
};

const signOut = async () => {
setBusy(true);
try {
await fetch("/api/auth/oidc/logout", { method: "POST" });
} catch {
// Local sign-out state is authoritative server-side; refresh shows it.
} finally {
setBusy(false);
void refresh();
}
};

return (
<SettingsCard
title="Remote sign-in"
description="OIDC login to the external mecated deployment. Tokens stay in the Studio server and never reach this browser."
>
{failed ? (
<Note>The sign-in status could not be read right now.</Note>
) : !status ? (
<Note>Checking sign-in status…</Note>
) : status.state === "not-configured" ? (
<Note>
{status.problem ||
"Not configured. Set MECATL_OIDC_ISSUER and MECATL_OIDC_CLIENT_ID (and optionally MECATL_OIDC_AUDIENCE) in Studio's environment to sign in to an OIDC-protected deployment."}
</Note>
) : (
<div className="divide-y divide-border/60">
{status.state === "signed-in" ? (
<SettingsRow
label="Signed in"
description={
status.email ||
status.subject ||
"Authenticated to the deployment."
}
>
<Button
type="button"
variant="outline"
className="rounded-full"
disabled={busy}
onClick={() => void signOut()}
>
{busy ? "Signing out…" : "Sign out"}
</Button>
</SettingsRow>
) : (
<SettingsRow
label={
status.state === "expired" ? "Session expired" : "Not signed in"
}
description={
status.state === "expired"
? "The identity provider ended this session — sign in again."
: status.issuer
}
>
<Button
type="button"
variant="action"
className="rounded-full"
onClick={signIn}
>
{status.state === "expired" ? "Sign in again" : "Sign in"}
</Button>
</SettingsRow>
)}
</div>
)}
</SettingsCard>
);
}
2 changes: 2 additions & 0 deletions studio/src/app/workspace/settings/provider/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useHarnessRuntime } from "@/features/agent/hooks/use-harness-runtime";
import { useProviderManagement } from "@/features/agent/hooks/use-provider-management";
import { AboutDaemonCard } from "../_components/about-daemon-card";
import { OidcLoginCard } from "../_components/oidc-login-card";
import { ProviderSection } from "../_components/provider-section";
import { RuntimeStatusLine } from "../_components/runtime-status-line";

Expand All @@ -18,6 +19,7 @@ export default function ProviderSettingsPage() {
/>
{/* Remote-daemon login (H3): only external mode authenticates upstream,
and the card itself explains a half-configured issuer. */}
{runtime.mode === "external" && <OidcLoginCard />}
</>
);
}
Loading
Loading