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
37 changes: 37 additions & 0 deletions docs/url-fragments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Why Does This URL Look Weird?

agent-render links carry the artifact in the URL fragment:

```text
https://agent-render.com/#agent-render=v1.arx.1.<compressed-payload>
```

Everything before `#` loads the static app. Everything after `#` is the artifact payload the browser decodes locally.

## What the parts mean

- `agent-render` tells the app this hash belongs to agent-render.
- `v1` is the payload format version.
- `arx` is the compression codec.
- `1` is the arx dictionary version.
- `<compressed-payload>` is the encoded artifact bundle.

For non-arx links the shape is shorter:

```text
#agent-render=v1.<codec>.<payload>
```

where `<codec>` is `plain`, `lz`, or `deflate`.

## Why arx exists

Artifacts can be bigger than a comfortable URL. `arx` keeps links shorter by applying an agent-render substitution dictionary, Brotli compression, and URL-safe binary-to-text encoding. The result can look strange because it is optimized for transport, not human reading.

## Privacy tradeoff

Fragments are useful because browsers do not send the part after `#` to the server during the initial page request. That means a static host can serve the viewer without receiving the artifact contents.

That is not the same thing as absolute secrecy. Fragment links can still appear in browser history, copied URLs, screenshots, link previews or tools that inspect full URLs, and any client-side analytics added later. Treat the link as bearer access to the artifact.

Use fragment links for quick static sharing. Use self-hosted UUID mode when the payload is too large, a chat app mangles long URLs, or you need short links and accept server-side storage.
38 changes: 38 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,23 @@ select {
border-bottom: 1px solid var(--border);
}

.nav-text-link {
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 600;
line-height: 1.4;
transition: color 150ms ease;
}

.nav-text-link:hover {
color: var(--text-primary);
}

.nav-text-link:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
outline-offset: 4px;
}

/* ── Full-bleed editorial sections ── */
.home-hero-section {
padding-top: 2rem;
Expand Down Expand Up @@ -244,6 +261,27 @@ select {
}
}

.site-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid var(--border);
padding-top: 1.25rem;
color: var(--text-soft);
font-family: var(--font-mono), monospace;
font-size: 0.78rem;
}

.site-footer a {
color: var(--text-muted);
}

.site-footer a:hover {
color: var(--text-primary);
}

/* ── Bento grid — tonal separation via 1px gaps ── */
.bento-grid {
display: grid;
Expand Down
107 changes: 107 additions & 0 deletions src/app/security/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Link from "next/link";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Security - agent-render",
description: "Security notes for agent-render static artifact links, markdown rendering, Mermaid, CSP, and reports.",
};

const sections = [
{
title: "What reaches the server",
body: [
"Static mode sends HTML, CSS, and JavaScript to the browser. Artifact payloads are not sent to the static host as part of the initial page request.",
"Fragment payloads stay out of the HTTP request path, query string, and request body for the static host.",
"The server can still receive normal static asset requests, IP address, user agent, referrer headers, and access logs from the hosting layer.",
],
},
{
title: "What can still leak",
body: [
"agent-render is zero-retention by host design. It is not a secret manager.",
"Artifact contents can still leak through copied URLs, browser history, bookmarks, screenshots, screen sharing, crash reports, extensions, referrer behavior, and future client-side analytics if someone adds them.",
"Do not put secrets, credentials, private keys, production tokens, or regulated data in artifact links.",
],
},
{
title: "Markdown and Mermaid",
body: [
"Markdown artifacts are rendered as GitHub-flavored Markdown and passed through rehype-sanitize before display.",
"React Markdown is configured with skipHtml, so raw HTML embedded in markdown is skipped instead of rendered.",
"Mermaid diagrams are only rendered from fenced mermaid code blocks. Mermaid runs with securityLevel: \"strict\" and falls back to showing source text if rendering fails.",
],
},
{
title: "CSP and security headers",
body: [
"The default static export does not require a runtime server. Configure Content-Security-Policy and other security headers at your static host or CDN.",
"Recommended headers include a restrictive Content-Security-Policy, Referrer-Policy, X-Content-Type-Options, Permissions-Policy, and HSTS when served over HTTPS.",
"If you loosen CSP for a custom deployment, review markdown, Mermaid, fonts, images, and script sources together before publishing.",
],
},
{
title: "Known limitations",
body: [
"URL fragments are client-side, but they are still visible to the browser, local machine, extensions, and anyone who receives the link.",
"Self-hosted UUID mode is a different deployment mode and stores payloads server-side by design.",
"The viewer treats payloads as untrusted input, but the safest policy is to keep sensitive material out of links entirely.",
],
},
] as const;

/**
* Public security page documenting the static host boundary and renderer safety posture.
* Keeps the copy direct and linkable for operators, reviewers, and security reports.
*/
export default function SecurityPage() {
return (
<main className="app-shell min-h-screen">
<header className="nav-bar sticky top-0 z-30 flex items-center justify-between px-4 py-3 sm:px-8 sm:py-4 lg:px-12">
<Link href="/" className="nav-text-link">
Agent Render
</Link>
</header>

<div className="mx-auto grid w-full max-w-4xl gap-10 px-4 py-10 sm:px-8 sm:py-16 lg:px-12">
<section className="border-b border-[color:var(--border)] pb-10">
<p className="section-kicker">Public security notes</p>
<h1 className="font-display mt-4 text-4xl font-bold leading-tight sm:text-6xl">Security</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-[color:var(--text-muted)] sm:text-lg">
agent-render is a static artifact viewer. Its core host boundary is simple: artifact data lives in the
URL fragment, so the static host does not receive it as part of the initial page request.
</p>
</section>

<section className="bento-grid">
{sections.map((section) => (
<article key={section.title} className="bento-card px-5 py-6 sm:px-7 sm:py-7">
<h2 className="text-lg font-bold leading-7">{section.title}</h2>
<ul className="mt-4 grid gap-3 pl-5 text-sm leading-7 text-[color:var(--text-muted)] marker:text-[color:var(--accent)] sm:text-base sm:leading-8">
{section.body.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
))}
</section>

<section className="border border-[color:var(--border)] px-5 py-6 sm:px-7 sm:py-7">
<p className="section-kicker">Reports</p>
<h2 className="mt-3 text-lg font-bold leading-7">Security contact</h2>
<p className="mt-4 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
Report security issues through the GitHub repository. Use a private vulnerability report when available;
otherwise open a minimal issue asking for a private contact path and do not include exploit details in public.
</p>
<a
href="https://github.com/baanish/agent-render/security/advisories/new"
rel="noreferrer"
target="_blank"
className="mt-4 inline-flex font-bold text-[color:var(--accent)]"
>
Open a private GitHub security advisory
</a>
</section>
</div>
</main>
);
}
98 changes: 98 additions & 0 deletions src/app/url-explainer/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Image from "next/image";
import { ArrowLeft, Link2, ShieldCheck, Zap } from "lucide-react";

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const iconPath = `${basePath}/icon.svg`;

export const metadata: Metadata = {
title: "Why does this URL look weird? - agent-render",
description: "A plain-English explainer for agent-render fragment payload URLs, arx compression, and privacy tradeoffs.",
};

export default function UrlExplainerPage() {
return (
<main className="app-shell min-h-screen">
<header className="nav-bar sticky top-0 z-30 flex items-center justify-between px-4 py-3 sm:px-8 sm:py-4 lg:px-12">
<a
href={`${basePath}/`}
className="flex items-center gap-2.5 rounded-[var(--radius-lg)] -m-1 p-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 sm:gap-3"
aria-label="Back to agent-render"
>
<div className="grid h-8 w-8 place-items-center rounded-[var(--radius-lg)] border border-[color:var(--border)] bg-[color:var(--surface-strong)] sm:h-9 sm:w-9">
<Image src={iconPath} alt="" width={24} height={24} className="h-4.5 w-4.5 sm:h-5 sm:w-5" priority unoptimized />
</div>
<span className="font-display text-lg font-semibold sm:text-xl">Agent Render</span>
</a>
</header>

<article className="mx-auto flex w-full max-w-4xl flex-col gap-10 px-4 pb-16 pt-10 sm:px-8 sm:pb-24 sm:pt-16 lg:px-12">
<a href={`${basePath}/`} className="inline-flex w-fit items-center gap-2 text-sm font-semibold text-[color:var(--accent)]">
<ArrowLeft className="h-4 w-4" />
Back to viewer
</a>

<section className="home-hero-section">
<p className="section-kicker">URL explainer</p>
<h1 className="font-display mt-4 text-[2.7rem] font-bold leading-[0.94] sm:mt-6 sm:text-6xl">
Why does this URL look weird?
</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-[color:var(--text-muted)] sm:mt-7 sm:text-lg">
The long part after <span className="font-mono">#agent-render=</span> is the artifact itself, compressed into the URL fragment so a static host can show it without receiving the content in the page request.
</p>
</section>

<section className="bento-grid">
<div className="bento-card bento-wide px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">The shape</p>
<p className="font-mono mt-4 break-all text-sm leading-7 text-[color:var(--text-muted)] sm:text-base">
https://agent-render.com/#agent-render=v1.arx.1.&lt;compressed-payload&gt;
</p>
<p className="mt-4 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
Everything before <span className="font-mono">#</span> loads the app. Everything after <span className="font-mono">#</span> stays in the browser and tells the app what to render.
</p>
</div>

<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<Link2 className="h-5 w-5 text-[color:var(--accent)]" />
<p className="section-kicker mt-4">v1</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)]">
The payload format version. It lets old and new links fail clearly instead of guessing.
</p>
</div>

<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<Zap className="h-5 w-5 text-[color:var(--accent)]" />
<p className="section-kicker mt-4">arx</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)]">
The compression method. It uses an agent-render dictionary, Brotli compression, and URL-safe text encoding to keep rich artifacts linkable.
</p>
</div>

<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<ShieldCheck className="h-5 w-5 text-[color:var(--accent)]" />
<p className="section-kicker mt-4">Privacy</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)]">
The static host does not receive fragment contents during the page request. The link is still not a secret: browser history, copied URLs, screenshots, logs from tools that inspect the full URL, and future client-side analytics can expose it.
</p>
</div>
</section>

<section className="home-stage-section">
<p className="section-kicker">In 30 seconds</p>
<div className="mt-5 grid gap-4">
<p className="text-base leading-8 text-[color:var(--text-muted)]">
A normal page URL asks the server for a route. An agent-render URL also carries a compressed artifact after the hash mark. Browsers do not send that hash to the server in the initial request, so the static app loads first and then decodes the artifact locally.
</p>
<p className="text-base leading-8 text-[color:var(--text-muted)]">
The weird-looking text is a transport format, not a tracking code. Shorter codecs like <span className="font-mono">deflate</span> and <span className="font-mono">arx</span> make markdown, code, diffs, CSV, and JSON fit into shareable links.
</p>
<p className="text-base leading-8 text-[color:var(--text-muted)]">
Use fragment links for quick static sharing. Use the optional self-hosted UUID mode when the payload is too large, the target chat app mangles long links, or you need a short URL and accept server-side storage.
</p>
</div>
</section>
</article>
</main>
);
}
33 changes: 29 additions & 4 deletions src/components/viewer-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { LucideIcon } from "lucide-react";
Expand All @@ -18,6 +19,7 @@ import {
FileSpreadsheet,
FileText,
FolderKanban,
HelpCircle,
Printer,
ShieldCheck,
Sparkles,
Expand Down Expand Up @@ -62,6 +64,7 @@ const sampleCards = sampleLinks.map((link, index) => ({
}));

const iconPath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`;
const urlExplainerPath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/url-explainer/`;

const ecosystemLinks = [
{
Expand Down Expand Up @@ -493,7 +496,12 @@ export function ViewerShell() {
<h1 className="font-display text-lg font-semibold tracking-[-0.03em] sm:text-xl">Agent Render</h1>
</a>

<ThemeToggle />
<div className="flex items-center gap-2 sm:gap-3">
<Link href="/security" className="nav-text-link">
Security
</Link>
<ThemeToggle />
</div>
</header>

<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 pb-12 pt-6 sm:gap-16 sm:px-8 sm:pb-24 sm:pt-12 lg:gap-20 lg:px-12 lg:pt-16">
Expand Down Expand Up @@ -647,6 +655,10 @@ export function ViewerShell() {
<p className="font-mono mt-4 text-base leading-8 text-[color:var(--text-muted)] sm:text-lg">
#{PAYLOAD_FRAGMENT_KEY}=v1.&lt;codec&gt;.&lt;payload&gt;
</p>
<a href={urlExplainerPath} className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[color:var(--accent)]">
<HelpCircle className="h-4 w-4" />
Why does this URL look weird?
</a>
</div>
<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">Static boundary</p>
Expand Down Expand Up @@ -762,7 +774,7 @@ export function ViewerShell() {
<p className="mt-4 max-w-3xl text-sm leading-7 text-[color:var(--text-muted)] sm:mt-5 sm:text-base sm:leading-8">
{activeArtifact
? `${getArtifactSubtitle(activeArtifact)} selected.`
: "Select a fragment above to render it here. Everything stays in the URL."}
: "Select a fragment above to render it here. Payloads stay off the host request path, but links still need care."}
</p>
</div>

Expand Down Expand Up @@ -812,9 +824,17 @@ export function ViewerShell() {
))}
<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">Security</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
The payload never leaves the URL hash. Rendering is entirely client-side.
<Link href="/security" className="mt-3 inline-flex items-center gap-2 text-base font-semibold leading-6 text-[color:var(--accent)]">
Read the security page
<ArrowUpRight className="h-4 w-4" />
</Link>
<p className="mt-2 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
Fragment payloads stay out of the static host request path, but links are not secret-safe.
</p>
<a href={urlExplainerPath} className="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-[color:var(--accent)]">
Read the privacy tradeoff
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">Hosting</p>
Expand All @@ -826,6 +846,11 @@ export function ViewerShell() {
</section>
</section>
)}

<footer className="site-footer print-hide-on-markdown">
<span>agent-render</span>
<Link href="/security">Security</Link>
</footer>
</div>
</main>
);
Expand Down
2 changes: 1 addition & 1 deletion tests/components/viewer-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("ViewerShell homepage", () => {
expect(screen.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible();
expect(screen.getByRole("link", { name: /github/i })).toBeVisible();
expect(screen.getByRole("link", { name: /payload format docs/i })).toBeVisible();
expect(screen.getByRole("link", { name: /security page/i })).toBeVisible();
expect(screen.getByRole("link", { name: /safety.*security page/i })).toBeVisible();
expect(screen.getByRole("link", { name: /openclaw/i })).toBeVisible();
});
});
Loading
Loading