Skip to content
Merged
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
39 changes: 39 additions & 0 deletions cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,45 @@ GHOST_STEP_TIMEOUT_MS="30000"
# this is memory-bound. Default 2.
WORKER_CONCURRENCY="2"

# ---------------------------------------------------------------------------
# Recording in a cloud browser
#
# Lets someone record a workflow by doing it once in a browser Ghost runs for
# them — no extension to install, no trace file to produce. The browser lives in
# the worker (the web app runs on request-scoped functions and cannot hold one);
# the user drives it over a WebSocket.
#
# GHOST_CAPTURE_KEY must be THE SAME VALUE on the web app and the worker. It is
# what lets the worker — which has no session table and never calls back — trust
# that a connecting socket belongs to someone the web app just authenticated.
# Unset means the feature is off: the UI does not offer it and the worker opens
# no port. That is deliberate. There is no "on but unauthenticated" mode, which
# would be a browser anyone who can reach the port may drive.
#
# openssl rand -base64 32
#
# The value below is dev-only and, living in this repository, public: anyone
# holding it can open a capture session against any organization. Replace it
# before this is reachable from anywhere but your machine.
GHOST_CAPTURE_KEY="dev-only-capture-key-change-me-openssl-rand-base64-32"

# Port the worker's HTTP server listens on: `/health` always, `/capture` when a
# key is set above. Falls back to `PORT` (which Render, Railway and Fly inject)
# and then to 8787, so the worker needs no extra configuration on a host — and
# no second service. Note the worker must be that host's *web service* type;
# a background worker accepts no inbound connections, so the capture socket
# would be unreachable. See docs/DEPLOY.md.
GHOST_CAPTURE_PORT="8787"

# Concurrent capture sessions per worker process, each holding its own browser.
# Default 3. Raising it trades memory against how many people can record at once
# — and the same process is executing runs.
GHOST_CAPTURE_MAX_SESSIONS="3"

# Where the browser connects. Public (NEXT_PUBLIC_) because the user's browser
# opens this socket directly. Use wss:// anywhere but localhost.
NEXT_PUBLIC_GHOST_CAPTURE_URL="ws://localhost:8787/capture"

# ---------------------------------------------------------------------------
# Agent plugin / MCP (optional — local dogfood)
# Agents may list/preview/start runs; they cannot approve. Sign in and create a
Expand Down
23 changes: 23 additions & 0 deletions cloud/apps/web/src/app/(app)/recordings/capture/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Link from "next/link";
import { CaptureLauncher } from "@/components/capture-launcher";

export const metadata = { title: "Record a workflow" };

export default function CapturePage() {
return (
<div className="mx-auto max-w-5xl space-y-6">
<div>
<Link href="/recordings" className="text-sm text-[var(--color-muted)] hover:underline">
← Recordings
</Link>
<h1 className="mt-2 text-xl font-semibold">Record a workflow</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Do the task once in the browser below. Ghost watches what you click and type, and turns
it into steps you review and edit before anything runs.
</p>
</div>

<CaptureLauncher />
</div>
);
}
19 changes: 19 additions & 0 deletions cloud/apps/web/src/app/(app)/recordings/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Link from "next/link";
import { RecordingUploadForm } from "@/components/recording-upload-form";
import { Button } from "@/components/ui/button";
import { Card, CardBody } from "@/components/ui/card";

export default function NewRecordingPage() {
return (
Expand All @@ -15,6 +17,23 @@ export default function NewRecordingPage() {
</p>
</div>

{/* The path most people should take. Uploading a trace file assumes you
already have one, which assumes a recorder you had to install — a fine
option for someone who wants it, and the wrong thing to lead with. */}
<Card>
<CardBody className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Record in a browser instead</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Do the task once in a browser Ghost runs for you. Nothing to install.
</p>
</div>
<Link href="/recordings/capture">
<Button>Record a workflow</Button>
</Link>
</CardBody>
</Card>

<RecordingUploadForm />
</div>
);
Expand Down
102 changes: 102 additions & 0 deletions cloud/apps/web/src/app/api/recordings/capture/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { auth } from "@/auth";
import {
CAPTURE_TICKET_TTL_SECONDS,
captureConfigured,
mintCaptureTicket,
newCaptureSessionId,
} from "@ghost/core/recording/capture";

/**
* Open a remote capture session: a browser in the worker that the user drives
* to demonstrate a workflow.
*
* This route does not start anything. It authenticates the caller — the one
* thing the worker cannot do, having no session table — and hands back a
* short-lived signed ticket plus the socket to present it on. The browser is
* launched by the worker when that socket connects, so a request that is
* abandoned in the two minutes before then costs nothing.
*
* No `Recording` row is created here either. It is created by `ingestTrace`
* when a trace exists, which is the only moment at which a recording is a real
* thing; a user who opens this page and closes it leaves nothing behind to
* explain.
*
* Touches: authentication (session), no database, no network.
*/

/** Where a capture opens when the user gives no URL of their own. */
const DEFAULT_START_URL = "about:blank";

export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.orgId) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}

const socketUrl = process.env.NEXT_PUBLIC_GHOST_CAPTURE_URL;
if (!captureConfigured() || !socketUrl) {
// Off, not broken. Said plainly so the operator knows which of the two
// variables to set rather than reading it as a bug.
return Response.json(
{
error:
"Recording in a cloud browser is not enabled on this deployment. It needs " +
"GHOST_CAPTURE_KEY (the same value on the web app and the worker) and " +
"NEXT_PUBLIC_GHOST_CAPTURE_URL pointing at the worker's capture socket.",
},
{ status: 501 },
);
}

const body = (await req.json().catch(() => ({}))) as { startUrl?: unknown };
const startUrl = normalizeStartUrl(body.startUrl);
if (!startUrl) {
return Response.json(
{ error: "startUrl must be an http or https address" },
{ status: 400 },
);
}

const sessionId = newCaptureSessionId();
// `startUrl` is signed into the ticket rather than sent alongside it, so the
// page the browser opens on is the one this authenticated request asked for
// and not something swapped in on the way to the worker.
const ticket = mintCaptureTicket({
sessionId,
orgId: session.user.orgId,
userId: session.user.id ?? null,
startUrl,
});

return Response.json({
sessionId,
ticket,
socketUrl,
startUrl,
expiresInSeconds: CAPTURE_TICKET_TTL_SECONDS,
});
}

/**
* Only `http`/`https`, and nothing scheme-less.
*
* The worker opens this in a real browser, so `file:` would read the
* container's disk and `javascript:` would run in whatever page is loaded.
* Neither is a workflow anyone is demonstrating.
*/
function normalizeStartUrl(input: unknown): string | null {
if (input === undefined || input === null || input === "") return DEFAULT_START_URL;
if (typeof input !== "string") return null;
const trimmed = input.trim();
if (!trimmed) return DEFAULT_START_URL;

const candidate = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.toString();
}
98 changes: 98 additions & 0 deletions cloud/apps/web/src/components/capture-launcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import { useState } from "react";
// The Node-free half of the contract. Importing from `recording/capture`
// instead would pull `node:crypto` into the client bundle and fail the build.
import { CAPTURE_VIEWPORT } from "@ghost/core/recording/capture-protocol";
import { Button } from "@/components/ui/button";
import { Card, CardBody } from "@/components/ui/card";
import { FieldLabel } from "@/components/ui/input";
import { CaptureSession } from "@/components/capture-session";

/**
* Asks where to start, then hands off to the live session.
*
* Split from `CaptureSession` because the ticket is short-lived by design: it
* is minted at the moment the user commits to starting, and the socket opens
* immediately after. Minting one when the page loads would leave a credential
* sitting in a tab nobody is looking at.
*/

interface Opened {
socketUrl: string;
ticket: string;
startUrl: string;
}

export function CaptureLauncher() {
const [startUrl, setStartUrl] = useState("");
const [opening, setOpening] = useState(false);
const [error, setError] = useState<string | null>(null);
const [session, setSession] = useState<Opened | null>(null);

async function start() {
setOpening(true);
setError(null);
try {
const res = await fetch("/api/recordings/capture", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ startUrl }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body?.error ?? `could not start a session (${res.status})`);
setSession({ socketUrl: body.socketUrl, ticket: body.ticket, startUrl: body.startUrl });
} catch (err) {
setError((err as Error).message);
} finally {
setOpening(false);
}
}

if (session) {
return (
<CaptureSession
socketUrl={session.socketUrl}
ticket={session.ticket}
startUrl={session.startUrl}
width={CAPTURE_VIEWPORT.width}
height={CAPTURE_VIEWPORT.height}
/>
);
}

return (
<Card>
<CardBody className="space-y-4">
<div>
<FieldLabel htmlFor="start-url">Start at</FieldLabel>
<input
id="start-url"
value={startUrl}
onChange={(e) => setStartUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void start();
}}
placeholder="mail.google.com"
spellCheck={false}
className="mt-1 h-10 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm"
/>
<p className="mt-1 text-xs text-[var(--color-muted)]">
The page the workflow begins on. You can navigate anywhere once it opens.
</p>
</div>

{error && <p className="text-sm text-[var(--color-danger)]">{error}</p>}

<div className="flex items-center gap-3">
<Button onClick={() => void start()} disabled={opening}>
{opening ? "Starting a browser…" : "Start recording"}
</Button>
<span className="text-xs text-[var(--color-muted)]">
Nothing is saved until you press Save at the end.
</span>
</div>
</CardBody>
</Card>
);
}
Loading
Loading