Skip to content
Open
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
8 changes: 4 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,10 @@ func (app *App) setupEvents() {
app.eventsCtx = ctx
setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "question-batches", app.Questions.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "question-notifications", app.Questions.SubscribeNotifications, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "question-batches", app.Questions.Subscribe, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "question-notifications", app.Questions.SubscribeNotifications, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "run-completions", app.runCompletions.Subscribe, app.events)
Expand Down
8 changes: 4 additions & 4 deletions internal/app/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ func NewForTest(ctx context.Context) *App {

eventsCtx, cancel := context.WithCancel(ctx)
app.eventsCtx = eventsCtx
setupSubscriber(eventsCtx, app.serviceEventsWG, "permissions",
setupSubscriberMustDeliver(eventsCtx, app.serviceEventsWG, "permissions",
app.Permissions.Subscribe, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "permissions-notifications",
setupSubscriberMustDeliver(eventsCtx, app.serviceEventsWG, "permissions-notifications",
app.Permissions.SubscribeNotifications, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "question-batches",
setupSubscriberMustDeliver(eventsCtx, app.serviceEventsWG, "question-batches",
app.Questions.Subscribe, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "question-notifications",
setupSubscriberMustDeliver(eventsCtx, app.serviceEventsWG, "question-notifications",
app.Questions.SubscribeNotifications, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "agent-notifications",
app.agentNotifications.Subscribe, app.events)
Expand Down
5 changes: 5 additions & 0 deletions internal/oauth/callback/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# General Guidelines

- always format CSS, HTML, and JS files with `prettier`
- keep Go template actions on a single line inside HTML tags; prettier
reformats multi-line actions into something the template parser rejects
13 changes: 13 additions & 0 deletions internal/oauth/callback/heartbit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
187 changes: 187 additions & 0 deletions internal/oauth/callback/page.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
:root {
/* Dark mode surface colors - charmtone dark palette. */
--bg: #201f26;
--bg-card: #2d2c35;
--bg-inset: #201f26;
--text: #fffaf1;
--text-muted: #858392;
--border: #4d4c57;
--brand-link: #8b75ff;

/* Charmtone accents (identical in both schemes). */
--julep: #00ffb2;
--coral: #ff577d;
--hazy: #8b75ff;
--squid: #858392;
}

@media (prefers-color-scheme: light) {
:root {
--bg: #f0f0f0;
--bg-card: #fbfbfb;
--bg-inset: #f4f4f6;
--text: #201f26;
--text-muted: #4d4c57;
--border: #dddce3;
--brand-link: #6b50ff;
}
}

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family:
"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}

.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 30rem;
text-align: center;
}

.card.ok {
--accent: var(--julep);
}

.card.failed {
--accent: var(--coral);
}

.mark {
width: 4.5rem;
margin: 0 auto 1.75rem;
}

.mark svg {
width: 100%;
height: auto;
display: block;
}

/* Tint the heart to match the outcome so the state is legible before a
single word is read. */
.card.failed .mark .heart {
fill: var(--coral);
}

h1 {
font-size: 1.375rem;
font-weight: 500;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
}

.detail {
color: var(--text-muted);
font-size: 0.875rem;
}

.detail .subject {
color: var(--hazy);
}

.reason {
margin-top: 1.5rem;
padding: 0.875rem 1rem;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 8px;
text-align: left;
font-size: 0.8125rem;
color: var(--text-muted);
overflow-wrap: anywhere;
}

.reason .code {
display: block;
color: var(--accent);
margin-bottom: 0.25rem;
}

.status {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.8125rem;
color: var(--text-muted);
/* Hold the line's height so swapping the message never shifts the card. */
min-height: 1.6em;
}

.count {
color: var(--text);
}

/* An accent bar across the top of the card, tinted by outcome. On a
successful page it doubles as a countdown, draining over the closing
delay so the wait is visible as well as spelled out. It bleeds through
the card padding to sit flush in the top corners. */
.rail {
margin: -2.5rem -2.5rem 2.5rem;
height: 3px;
/* Match the card's inner radius so the ends tuck into the corners. */
border-radius: 11px 11px 0 0;
background: var(--border);
overflow: hidden;
}

.rail span {
display: block;
height: 100%;
width: 100%;
background: var(--accent);
transform-origin: left;
}

/* Only a running countdown drains. Otherwise the bar stays full, which is
also what a reader without JavaScript sees. */
.rail.running span {
animation: drain var(--delay, 5s) linear forwards;
}

@keyframes drain {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}

@media (prefers-reduced-motion: reduce) {
.rail.running span {
animation: none;
}
}

.brand {
margin-top: 1.5rem;
font-size: 0.75rem;
color: var(--squid);
}

.brand a {
color: var(--brand-link);
text-decoration: none;
}

.brand a:hover {
text-decoration: underline;
}
138 changes: 138 additions & 0 deletions internal/oauth/callback/page.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Package callback renders the browser page a user lands on at the end of
// an OAuth redirect flow.
//
// The page is the only part of authorization the user sees outside the
// terminal, so it is worth more than a line of plain text: it reports
// whether authorization worked, names what was authorized, explains any
// failure in the provider's own words, and offers to close itself.
//
// Rendering is self-contained. Markup, styles, script, and artwork are
// embedded in the binary, so the page works with no network access beyond
// an optional web font.
package callback

import (
"embed"
"encoding/base64"
"fmt"
"html/template"
"io"
"net/http"
"time"
)

//go:embed page.html page.css page.js heartbit.svg
var assets embed.FS

// closeDelay is how long the page counts down before asking the browser to
// close the tab. Long enough to read the outcome, short enough not to feel
// like waiting.
const closeDelay = 5 * time.Second

// tmpl is parsed once at startup. A parse failure means the embedded
// template is broken, which is a build-time mistake rather than anything a
// user can cause, so panicking here fails fast and loudly.
var tmpl = template.Must(template.ParseFS(assets, "page.html"))

// Result describes the outcome of an authorization attempt.
type Result struct {
// Subject names what was being authorized, such as an MCP server name.
// Optional; when empty the page simply omits it.
Subject string

// ErrorCode is the OAuth error code (for example "access_denied").
// A non-empty value renders the page in its failure state.
ErrorCode string

// ErrorDescription is the provider's human-readable explanation. It is
// shown alongside ErrorCode and may be empty.
ErrorDescription string
}

// Failed reports whether the result describes a failed authorization.
func (r Result) Failed() bool { return r.ErrorCode != "" }

// Write renders the callback page for the given result to w. It always
// writes a complete page: if template execution somehow fails midway, the
// error is returned so the caller can log it, but the user is never shown
// a blank tab.
func Write(w io.Writer, r Result) error {
css, err := assets.ReadFile("page.css")
if err != nil {
return fmt.Errorf("read callback stylesheet: %w", err)
}
js, err := assets.ReadFile("page.js")
if err != nil {
return fmt.Errorf("read callback script: %w", err)
}
mark, err := assets.ReadFile("heartbit.svg")
if err != nil {
return fmt.Errorf("read callback artwork: %w", err)
}

data := struct {
Title string
Kind string
Heading string
Detail string
Subject string
ErrorCode string
ErrorDescription string
Status string
CloseDelay int
CSS template.CSS
JS template.JS
Heartbit template.HTML
Favicon template.URL
}{
Subject: r.Subject,
ErrorCode: r.ErrorCode,
ErrorDescription: r.ErrorDescription,
CSS: template.CSS(css),
JS: template.JS(js),
Heartbit: template.HTML(mark),
Favicon: template.URL("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(mark)),
}

if r.Failed() {
data.Title = "Authorization failed — Crush"
data.Kind = "failed"
data.Heading = "Authorization failed"
data.Detail = "Crush was not granted access to"
if r.Subject == "" {
data.Detail = "Crush was not granted access."
}
// A failed page keeps itself open: the reader needs the reason,
// and closing the tab out from under them would take it away.
data.Status = "Close this tab and try again from Crush."
} else {
data.Title = "Authorized — Crush"
data.Kind = "ok"
data.Heading = "You're all set"
data.Detail = "Crush is now connected to"
if r.Subject == "" {
data.Detail = "Crush is now connected."
}
// Replaced by the countdown as soon as the script runs, so this
// text is what a reader without JavaScript is left with.
data.Status = "You can close this tab."
data.CloseDelay = int(closeDelay.Seconds())
}

return tmpl.Execute(w, data)
}

// Serve writes the callback page as a complete HTTP response, choosing a
// status code that matches the outcome. Errors are logged by the caller;
// the page itself is best effort because by this point the browser is
// already committed to rendering whatever arrives.
func Serve(w http.ResponseWriter, r Result) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The page reflects a one-time authorization result and must never be
// replayed from cache on a later visit to the same localhost URL.
w.Header().Set("Cache-Control", "no-store")
if r.Failed() {
w.WriteHeader(http.StatusBadRequest)
}
return Write(w, r)
}
Loading
Loading