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
10 changes: 10 additions & 0 deletions services/hackbot-ui/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env
.env.local
*.db
*.db-shm
*.db-wal
npm-debug.log
README.md
17 changes: 17 additions & 0 deletions services/hackbot-ui/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Base URL of the hackbot-api service (no trailing slash).
HACKBOT_API_URL=http://localhost:8080

# API key sent as the X-API-Key header. Must match hackbot-api's
# EXTERNAL_API_KEY. Stays server-side — it is never shipped to the browser.
HACKBOT_API_KEY=

# Public base URL of THIS web app, used by better-auth for callbacks.
BETTER_AUTH_URL=http://localhost:3000

# Random 32+ char secret used to sign sessions. Generate with `openssl rand -base64 32`.
BETTER_AUTH_SECRET=

# Google OAuth client (console.cloud.google.com → APIs & Services → Credentials).
# Authorized redirect URI must be: <BETTER_AUTH_URL>/api/auth/callback/google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
12 changes: 12 additions & 0 deletions services/hackbot-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# The repo-root .gitignore ignores `lib/` (Python build artifacts). Re-include
# this Next.js app's lib/ source directory.
!/lib/
/node_modules
/.next
/out
/build
.env
.env.local
*.tsbuildinfo
next-env.d.ts.bak
.DS_Store
34 changes: 34 additions & 0 deletions services/hackbot-ui/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

FROM node:22-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
# Cloud Run injects its own PORT (8080) which the standalone server honors.
# Bind to all interfaces — the standalone server defaults HOSTNAME to
# "localhost", which would fail Cloud Run's health check.
ENV HOSTNAME=0.0.0.0

RUN useradd --create-home --shell /bin/bash app

# Next.js standalone output bundles only the files needed to run the server.
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER app
EXPOSE 3000
CMD ["node", "server.js"]
138 changes: 138 additions & 0 deletions services/hackbot-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Hackbot Launchpad

A small Next.js web UI for **demonstrating** the [`hackbot-api`](../hackbot-api).
Comment thread
suhaibmujahid marked this conversation as resolved.
It lets you:

- **Trigger** the `bug-fix` agent by entering a Bugzilla bug ID (plus optional
model / max-turns / effort).
- **Observe state** — the dashboard and the run detail page poll the API and
show each run's status live (`pending → running → succeeded / failed / timed_out`).
- **Read the result** on completion — the agent's summary findings (rendered as
an "Agent log" pane when a free-text log/output field is present) and the
artifacts written to the results bucket, each a **download link** (the browser
is redirected to a short-lived signed GCS URL).

> This is a demo surface, not a system of record. It has no list-runs endpoint
> upstream, so the browser remembers the runs you triggered in `localStorage`.

## Architecture

```
Browser ──> Next.js route handlers (/api/*) ──> hackbot-api (X-API-Key)
└─ better-auth (Google, @mozilla.com only)
```

The `hackbot-api` key never reaches the browser: every call goes through a
server-side route handler in `app/api/*` that injects the `X-API-Key` header
(see `lib/hackbot.ts`). Those handlers also re-validate the session.

### Authentication

Sign-in is **Google OAuth via [better-auth](https://better-auth.com)**, limited
to `@mozilla.com` accounts:

- **No database — fully stateless.** `lib/auth.ts` configures better-auth with
no `database`, so the session lives entirely in a signed + encrypted (JWE)
cookie and the server never queries any store to validate it. The only shared
state is `BETTER_AUTH_SECRET`. See better-auth's
[stateless session docs](https://better-auth.com/docs/concepts/session-management#basic-stateless-setup).
- The `@mozilla.com` restriction is enforced in two mode-independent layers:
the Google provider's `mapProfileToUser` rejects non-Mozilla identities during
the OAuth callback (before a session is issued), and `getAuthedEmail()`
(`lib/session.ts`) re-checks the domain on every proxy request. `hd: "mozilla.com"`
is also passed to Google as a UI hint.
- `middleware.ts` redirects unauthenticated visitors to `/login` (and returns
`401` JSON for `/api/*`).

## Endpoints used (hackbot-api)

| UI action | hackbot-api call |
| ----------------- | --------------------------------------- |
| Trigger a run | `POST /agents/bug-fix/runs` |
| Poll run state | `GET /runs/{run_id}` |
| Download artifact | `GET /runs/{run_id}/artifacts/{path}` † |
| (available) | `GET /agents` |

## Local development

1. Install dependencies:

```bash
npm install
```

2. Configure the environment:

```bash
cp .env.example .env.local
# fill in HACKBOT_API_URL, HACKBOT_API_KEY,
# BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
```

For the Google OAuth client, add this authorized redirect URI:
`http://localhost:3000/api/auth/callback/google`

(No database setup or migration step — auth is stateless.)

3. Run it:

```bash
npm run dev
```

Open http://localhost:3000 — you'll be redirected to `/login`.

## Production build / container

```bash
docker build -t hackbot-ui -f services/hackbot-ui/Dockerfile services/hackbot-ui
docker run -p 3000:3000 --env-file services/hackbot-ui/.env.local hackbot-ui
```

The image uses Next.js `output: "standalone"`.

### Cloud Run

Stateless auth makes this Cloud Run-friendly out of the box: sessions are
self-contained cookies, so they survive scale-to-zero and work across any number
of instances — **as long as every instance shares the same `BETTER_AUTH_SECRET`**
(set it as a secret/env var on the service). No database to provision.

Use `./deploy.sh` (build → push to Artifact Registry → `gcloud run deploy`). It
keeps secrets in Secret Manager and reads everything else from env vars. The
service runs as a **dedicated least-privilege service account**
(`hackbot-ui-run@<project>`) that the script creates and grants
`secretmanager.secretAccessor` on just the three secrets it reads — no other GCP
permissions (it reaches hackbot-api over HTTPS with the API key, not via IAM).

The hackbot-api key reuses the existing shared **`external-api-key`** secret
(override with `API_KEY_SECRET=...`), so only two UI-specific secrets need
creating: `hackbot-ui-auth-secret` and `hackbot-ui-google-secret`.

```bash
# one-time: enable APIs + create the two UI secrets (see the header of deploy.sh).
# The deployer needs run.admin + iam.serviceAccountUser on the new SA.

# first deploy (no BETTER_AUTH_URL yet — Cloud Run assigns the URL):
PROJECT=my-proj REGION=us-central1 \
HACKBOT_API_URL=https://hackbot-api-xxxx.run.app \
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com \
./deploy.sh

# then: add <printed-url>/api/auth/callback/google to the Google OAuth client,
# and re-run with BETTER_AUTH_URL=<printed-url> to finalize.
```

`HACKBOT_API_URL` is the hackbot-api's public Cloud Run URL; `HACKBOT_API_KEY`
must match its `X-API-Key`.

## Environment variables

| Variable | Purpose |
| ---------------------- | -------------------------------------------------- |
| `HACKBOT_API_URL` | Base URL of hackbot-api (no trailing slash) |
| `HACKBOT_API_KEY` | Value for the `X-API-Key` header (server-side) |
| `BETTER_AUTH_URL` | Public base URL of this app |
| `BETTER_AUTH_SECRET` | Session signing secret (`openssl rand -base64 32`) |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
19 changes: 19 additions & 0 deletions services/hackbot-ui/app/api/agents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";

import { HackbotError, listAgents } from "@/lib/hackbot";
import { getAuthedEmail } from "@/lib/session";

export const dynamic = "force-dynamic";

export async function GET() {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const agents = await listAgents();
return NextResponse.json(agents);
} catch (err) {
const status = err instanceof HackbotError ? err.status : 500;
return NextResponse.json({ error: (err as Error).message }, { status });
}
}
5 changes: 5 additions & 0 deletions services/hackbot-ui/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";

import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";

import { getArtifactDownloadUrl, HackbotError } from "@/lib/hackbot";
import { getAuthedEmail } from "@/lib/session";

export const dynamic = "force-dynamic";

// GET /api/runs/:runId/artifacts/*path
// Resolves a signed download URL from hackbot-api and redirects the browser
// straight to GCS, so artifact bytes never stream through this server and the
// X-API-Key stays server-side.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ runId: string; path: string[] }> }
) {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { runId, path } = await params;
const artifactName = path.join("/"); // catch-all segments are pre-decoded

try {
const { url } = await getArtifactDownloadUrl(runId, artifactName);
return NextResponse.redirect(url, 302);
} catch (err) {
Comment thread
suhaibmujahid marked this conversation as resolved.
const status = err instanceof HackbotError ? err.status : 500;
return NextResponse.json({ error: (err as Error).message }, { status });
}
}
25 changes: 25 additions & 0 deletions services/hackbot-ui/app/api/runs/[runId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";

import { getRun, HackbotError } from "@/lib/hackbot";
import { getAuthedEmail } from "@/lib/session";

export const dynamic = "force-dynamic";

// GET /api/runs/:runId — proxy the full run document (state, summary, artifacts).
export async function GET(
_req: Request,
{ params }: { params: Promise<{ runId: string }> }
) {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { runId } = await params;
try {
const run = await getRun(runId);
return NextResponse.json(run);
} catch (err) {
const status = err instanceof HackbotError ? err.status : 500;
return NextResponse.json({ error: (err as Error).message }, { status });
}
}
41 changes: 41 additions & 0 deletions services/hackbot-ui/app/api/runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";

import { createRun, HackbotError } from "@/lib/hackbot";
import { getAuthedEmail } from "@/lib/session";

export const dynamic = "force-dynamic";

// POST /api/runs { agent: string, inputs: object }
// Triggers a new agent run via hackbot-api.
export async function POST(req: NextRequest) {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { agent, inputs } = (body ?? {}) as {
agent?: string;
inputs?: Record<string, unknown>;
};

if (!agent || typeof agent !== "string") {
return NextResponse.json(
{ error: "Missing 'agent' field" },
{ status: 400 }
);
}

try {
const run = await createRun(agent, inputs ?? {});
return NextResponse.json(run, { status: 201 });
Comment thread
suhaibmujahid marked this conversation as resolved.
} catch (err) {
const status = err instanceof HackbotError ? err.status : 500;
return NextResponse.json({ error: (err as Error).message }, { status });
}
}
Loading