diff --git a/docs/url-fragments.md b/docs/url-fragments.md new file mode 100644 index 0000000..b703ff0 --- /dev/null +++ b/docs/url-fragments.md @@ -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. +``` + +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. +- `` is the encoded artifact bundle. + +For non-arx links the shape is shorter: + +```text +#agent-render=v1.. +``` + +where `` 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. diff --git a/src/app/globals.css b/src/app/globals.css index c69e4d5..4b6e41e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; @@ -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; diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx new file mode 100644 index 0000000..ad762a1 --- /dev/null +++ b/src/app/security/page.tsx @@ -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 ( +
+
+ + Agent Render + +
+ +
+
+

Public security notes

+

Security

+

+ 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. +

+
+ +
+ {sections.map((section) => ( +
+

{section.title}

+
    + {section.body.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+ +
+

Reports

+

Security contact

+

+ 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. +

+ + Open a private GitHub security advisory + +
+
+
+ ); +} diff --git a/src/app/url-explainer/page.tsx b/src/app/url-explainer/page.tsx new file mode 100644 index 0000000..fd05a70 --- /dev/null +++ b/src/app/url-explainer/page.tsx @@ -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 ( +
+
+ +
+ +
+ Agent Render +
+
+ +
+ + + Back to viewer + + +
+

URL explainer

+

+ Why does this URL look weird? +

+

+ The long part after #agent-render= is the artifact itself, compressed into the URL fragment so a static host can show it without receiving the content in the page request. +

+
+ +
+
+

The shape

+

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

+

+ Everything before # loads the app. Everything after # stays in the browser and tells the app what to render. +

+
+ +
+ +

v1

+

+ The payload format version. It lets old and new links fail clearly instead of guessing. +

+
+ +
+ +

arx

+

+ The compression method. It uses an agent-render dictionary, Brotli compression, and URL-safe text encoding to keep rich artifacts linkable. +

+
+ +
+ +

Privacy

+

+ 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. +

+
+
+ +
+

In 30 seconds

+
+

+ 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. +

+

+ The weird-looking text is a transport format, not a tracking code. Shorter codecs like deflate and arx make markdown, code, diffs, CSV, and JSON fit into shareable links. +

+

+ 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. +

+
+
+
+
+ ); +} diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 77a05d7..ca4eb83 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -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"; @@ -18,6 +19,7 @@ import { FileSpreadsheet, FileText, FolderKanban, + HelpCircle, Printer, ShieldCheck, Sparkles, @@ -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 = [ { @@ -493,7 +496,12 @@ export function ViewerShell() {

Agent Render

- +
+ + Security + + +
@@ -647,6 +655,10 @@ export function ViewerShell() {

#{PAYLOAD_FRAGMENT_KEY}=v1.<codec>.<payload>

+ + + Why does this URL look weird? +

Static boundary

@@ -762,7 +774,7 @@ export function ViewerShell() {

{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."}

@@ -812,9 +824,17 @@ export function ViewerShell() { ))}

Security

-

- The payload never leaves the URL hash. Rendering is entirely client-side. + + Read the security page + + +

+ Fragment payloads stay out of the static host request path, but links are not secret-safe.

+ + Read the privacy tradeoff + +

Hosting

@@ -826,6 +846,11 @@ export function ViewerShell() { )} + +
); diff --git a/tests/components/viewer-shell.test.tsx b/tests/components/viewer-shell.test.tsx index 060c0e7..2fd3238 100644 --- a/tests/components/viewer-shell.test.tsx +++ b/tests/components/viewer-shell.test.tsx @@ -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(); }); }); diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts index c3762b2..000b0f1 100644 --- a/tests/e2e/viewer.spec.ts +++ b/tests/e2e/viewer.spec.ts @@ -21,10 +21,23 @@ test("renders the zero-retention homepage when no fragment is present", async ({ await expect(page.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible(); await expect(page.getByRole("link", { name: /github/i })).toBeVisible(); await expect(page.getByRole("link", { name: /payload format docs/i })).toBeVisible(); - await expect(page.getByRole("link", { name: /security page/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /safety.*security page/i })).toBeVisible(); await expect(page.getByRole("link", { name: /openclaw/i })).toBeVisible(); }); +test("links to the public security page", async ({ page }) => { + await waitForViewerState(page, "empty"); + + await page.getByRole("link", { name: "Security" }).first().click(); + + await expect(page).toHaveURL(/\/security\/?$/); + await expect(page.getByRole("heading", { name: "Security", exact: true })).toBeVisible(); + await expect(page.getByText("Artifact payloads are not sent to the static host as part of the initial page request.")).toBeVisible(); + await expect(page.getByText("Fragment payloads stay out of the HTTP request path")).toBeVisible(); + await expect(page.getByText("React Markdown is configured with skipHtml")).toBeVisible(); + await expect(page.getByText("Mermaid runs with securityLevel: \"strict\"")).toBeVisible(); +}); + test("creates, copies, and previews a generated homepage link", async ({ page }) => { await waitForViewerState(page, "empty");