From 18b3b32aa7621dd544eeb4e594f02c435a8efcc1 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:00:34 -0400 Subject: [PATCH 01/32] viewer: kit html and choices artifact kinds Coding-agent surfaces need richer structure than markdown, but agent- invented styling is wasted tokens and a phishing surface. Two new kinds: - html: agent markup rendered against a design kit that ships in the viewer (ar-* components plus a pinned Tailwind utility subset, see docs/design-kit.md). Fragment payloads are mintable by anyone, so they render sanitized: no scripts, event handlers, inline styles, form controls, or foreign content; tabs and disclosure are viewer-owned JS. Server-injected payloads on self-hosted instances render verbatim at the operator's documented risk. - choices: a presentational decision list with stable option ids the reader answers with in chat. No response channel by design. The arx2/3/4 tuple wire format is pinned, so envelopes with new kinds drop those codecs from the candidate pool and ride arx/deflate/lz/plain. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/architecture.md | 2 +- docs/design-kit.md | 77 ++++ docs/payload-format.md | 56 ++- skills/agent-render-linking/SKILL.md | 45 ++- src/app/globals.css | 16 + src/app/kit.css | 353 ++++++++++++++++++ src/components/artifact-kind-icons.ts | 4 +- src/components/home/link-creator.tsx | 19 +- src/components/home/sample-link-data.ts | 14 + src/components/renderers/choices-renderer.tsx | 46 +++ src/components/renderers/html-renderer.tsx | 122 ++++++ src/components/viewer-shell.tsx | 16 +- src/components/viewer/artifact-stage.tsx | 61 ++- src/lib/html/sanitize-kit-html.ts | 234 ++++++++++++ src/lib/payload/arx-codec.ts | 6 + src/lib/payload/arx4-codec.ts | 4 + src/lib/payload/envelope.ts | 14 + src/lib/payload/examples.ts | 38 ++ src/lib/payload/fragment-arx.ts | 19 + src/lib/payload/link-creator.ts | 9 +- src/lib/payload/schema.ts | 59 ++- src/lib/payload/wire-format.ts | 65 +++- tests/arx4-codec.test.ts | 6 +- tests/components/artifact-selector.test.tsx | 2 +- tests/components/artifact-stage-raw.test.tsx | 1 + tests/e2e/helpers.ts | 4 +- tests/payload-new-kinds.test.ts | 118 ++++++ tests/sanitize-kit-html.test.ts | 76 ++++ 29 files changed, 1463 insertions(+), 25 deletions(-) create mode 100644 docs/design-kit.md create mode 100644 src/app/kit.css create mode 100644 src/components/renderers/choices-renderer.tsx create mode 100644 src/components/renderers/html-renderer.tsx create mode 100644 src/lib/html/sanitize-kit-html.ts create mode 100644 tests/payload-new-kinds.test.ts create mode 100644 tests/sanitize-kit-html.test.ts diff --git a/README.md b/README.md index 36bdc21..8d59c45 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ `agent-render` is a fully static, zero-retention artifact viewer for AI-generated outputs. -Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based sharing for markdown, code, diffs, CSV, and JSON so the payload stays in the browser URL fragment instead of being sent to a server. +Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based sharing for markdown, code, diffs, CSV, JSON, kit HTML dashboards, and choice lists so the payload stays in the browser URL fragment instead of being sent to a server. ## OpenClaw diff --git a/docs/architecture.md b/docs/architecture.md index 1986f30..f4fddad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ The static export also emits `sitemap.xml` at the site root (and under `NEXT_PUB - `csv` - table-focused data grid built from parsed rows and dynamic columns - `json` - lightweight read-only tree view plus a native raw source view -The viewer shell now routes all five artifact kinds through dynamically imported client-only renderers so the landing shell stays light and static-host friendly. +The viewer shell now routes all seven artifact kinds (markdown, code, diff, csv, json, html, choices) through dynamically imported client-only renderers so the landing shell stays light and static-host friendly. Kit `html` artifacts render sanitized on fragment links and verbatim only for server-injected self-hosted payloads; see `docs/design-kit.md`. When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, and markdown print actions. The landing/samples experience is only the empty state. diff --git a/docs/design-kit.md b/docs/design-kit.md new file mode 100644 index 0000000..94530ea --- /dev/null +++ b/docs/design-kit.md @@ -0,0 +1,77 @@ +# Design kit for `html` artifacts + +The `html` artifact kind renders agent-authored markup with a design system that ships in the +viewer. Agents supply structure and content; the viewer supplies the design, once, so models never +invent styling. Payload CSS is neither needed nor allowed: fragment payloads render sanitized, and +inline styles and `

text

'); + expect(output).toBe("

text

"); + }); + + it("strips event handler attributes", () => { + const output = sanitizeKitHtml('
text
'); + expect(output).toBe('
text
'); + }); + + it("strips javascript: hrefs but keeps https and mailto links", () => { + expect(sanitizeKitHtml('x')).toBe("x"); + expect(sanitizeKitHtml('x')).toBe('x'); + expect(sanitizeKitHtml('x')).toBe('x'); + }); + + it("forces noopener rel on target=_blank links and drops other targets", () => { + expect(sanitizeKitHtml('x')).toBe( + 'x', + ); + expect(sanitizeKitHtml('x')).toBe( + 'x', + ); + }); + + it("removes form controls entirely", () => { + const output = sanitizeKitHtml('

after

'); + expect(output).toBe("

after

"); + }); + + it("removes iframe, object, and svg subtrees", () => { + const output = sanitizeKitHtml('

kept

'); + expect(output).toBe("

kept

"); + }); + + it("drops id and name attributes to prevent DOM clobbering", () => { + const output = sanitizeKitHtml('
x
'); + expect(output).toBe('
x
'); + }); + + it("unwraps unknown tags but keeps their sanitized children", () => { + const output = sanitizeKitHtml("

inner

"); + expect(output).toBe("

inner

"); + }); + + it("keeps https and data:image sources on images, drops http", () => { + expect(sanitizeKitHtml('a')).toBe( + 'a', + ); + expect(sanitizeKitHtml('a')).toBe('a'); + expect(sanitizeKitHtml('')).toBe( + '', + ); + expect(sanitizeKitHtml('')).toBe(''); + }); + + it("keeps kit structure: tables, details, data-ar-* and aria attributes", () => { + const input = + '
h
v
More

body

'; + expect(sanitizeKitHtml(input)).toBe(input); + }); + + it("removes HTML comments", () => { + expect(sanitizeKitHtml("

a

b

")).toBe("

a

b

"); + }); +}); From 20618588e8ec70b96267b8be617356abfc1598ae Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:58:50 -0400 Subject: [PATCH 02/32] selfhosted: 7-day default TTL, env override, optional password gate An internet-reachable instance previously had no auth at all: anyone could create, overwrite, delete, or read artifacts, and the 24h sliding TTL was too short for mobile review workflows. - Default sliding TTL is now 7 days; AGENT_RENDER_TTL_HOURS overrides it and invalid values fail fast at startup. - Setting AGENT_RENDER_PASSWORD enables a fallback gate: writes and artifact API reads need Bearer or the auth cookie; browser pages get a minimal password form that sets an HMAC cookie once per device. Unset keeps today's open behavior. - BYO reverse-proxy auth stays the documented primary path. Co-Authored-By: Claude Fable 5 --- docs/deployment.md | 32 +-- public/openapi/selfhosted-artifacts.yaml | 45 ++++ selfhosted/db.ts | 2 +- selfhosted/server.ts | 139 ++++++++++- selfhosted/ttl.ts | 22 +- skills/selfhosted-agent-render/SKILL.md | 44 ++-- tests/selfhosted/auth.test.ts | 280 +++++++++++++++++++++++ tests/selfhosted/db.test.ts | 4 +- tests/selfhosted/ttl.test.ts | 63 ++++- 9 files changed, 588 insertions(+), 43 deletions(-) create mode 100644 tests/selfhosted/auth.test.ts diff --git a/docs/deployment.md b/docs/deployment.md index 89cc7fb..149c617 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,13 +74,15 @@ The server starts on port 3000. Create artifacts via `POST /api/artifacts` and v ### Environment variables -| Variable | Default | Description | -| -------------------- | ------------------------ | ------------------------------------------------------ | -| `PORT` | `3000` | Server listen port | -| `HOST` | `0.0.0.0` | Server bind address | -| `DB_PATH` | `./data/agent-render.db` | SQLite database file path | -| `OUT_DIR` | `out` | Path to the static build output | -| `SHUTDOWN_GRACE_MS` | `5000` | Drain window on SIGTERM/SIGINT before a forced (non-zero) exit | +| Variable | Default | Description | +| --------------------------- | ------------------------ | --------------------------------------------------------------- | +| `PORT` | `3000` | Server listen port | +| `HOST` | `0.0.0.0` | Server bind address | +| `DB_PATH` | `./data/agent-render.db` | SQLite database file path | +| `OUT_DIR` | `out` | Path to the static build output | +| `AGENT_RENDER_TTL_HOURS` | `168` | Sliding artifact TTL in hours (positive integer) | +| `AGENT_RENDER_PASSWORD` | unset | Shared-secret fallback auth; prefer a reverse proxy | +| `SHUTDOWN_GRACE_MS` | `5000` | Drain window before a forced (non-zero) exit on SIGTERM/SIGINT | ### Docker Compose @@ -135,16 +137,20 @@ pm2 start selfhosted/dist/server.js --name agent-render The server uses SQLite with WAL mode. The database file is created automatically at the path specified by `DB_PATH`. The parent directory is created if it does not exist. -Artifacts have a 24-hour sliding TTL. Each successful view extends the expiry. Expired entries are lazily cleaned on read, swept automatically on startup and once an hour, and can be batch-removed on demand via `POST /api/cleanup`. +Artifacts have a seven-day sliding TTL by default. Set `AGENT_RENDER_TTL_HOURS` to a positive integer to change it. Each successful view extends the expiry by the configured duration. Expired entries are lazily cleaned on read, swept automatically on startup and once an hour, and can be batch-removed on demand via `POST /api/cleanup`. ### Auth and access control -The self-hosted server does not include built-in authentication. Options for protecting it: +Put the self-hosted server behind your existing reverse proxy or identity-aware access layer when authentication is required. nginx, Caddy, Traefik, Cloudflare Access, and similar products provide stronger policy, SSO, audit, and secret-management options than the server's built-in fallback. -- **Public**: No additional configuration. Recommended for public/non-sensitive artifacts that benefit from short share-friendly links. -- **Cloudflare Tunnel + Zero Trust**: Expose the server through a Cloudflare Tunnel and add Access policies for authentication. This is the recommended approach for remote access with SSO. -- **Reverse proxy**: Place behind nginx, Caddy, or Traefik with HTTP basic auth, OAuth2 proxy, or mTLS. -- **Local only**: Set `HOST=127.0.0.1` to bind to localhost only. +For a small or local deployment without a separate auth layer, set `AGENT_RENDER_PASSWORD` to enable shared-secret fallback auth: + +- API write requests (`POST`, `PUT`, and `DELETE`, including `POST /api/cleanup`) accept `Authorization: Bearer `. The server returns a bearer challenge with API `401` responses. Same-origin browser clients may use the authentication cookie instead. +- Stored UUID viewer pages and other static HTML pages require the authentication cookie. Without it, the server returns a `401` sign-in page; submitting that form to `/auth` sets an `HttpOnly`, `Secure`, `SameSite=Lax` cookie and redirects back to the requested page. +- `GET /api/artifacts/{id}` requires the same bearer or cookie credentials as writes. Static assets, API discovery, and `GET /health` remain open. +- A protected request without valid credentials returns `401 Unauthorized`. + +The built-in password gates writes, browser pages, and artifact API reads. It is still a shared static secret, not per-user auth or an audit trail; use a reverse proxy or identity-aware proxy when you need real accounts. If `AGENT_RENDER_PASSWORD` is unset, the fallback is disabled and the server remains public; bind `HOST=127.0.0.1` if it should only be reachable locally. Every response carries baseline hardening headers: `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `X-Frame-Options: SAMEORIGIN`. HTML responses additionally carry a strict **`Content-Security-Policy`**. Its `script-src` allows only same-origin scripts, the build's own inline scripts (by `sha256` hash, derived at runtime from the served `index.html` so they never drift from the build), and — on a stored-artifact viewer page — the injected payload bootstrap (by a per-response `nonce`). So even if a renderer dependency regressed into an injection sink, attacker-controlled inline script in a stored payload cannot execute. It also includes `'wasm-unsafe-eval'`, which the arx-family codecs need to decompress Brotli via WebAssembly — this permits WebAssembly compilation but not JavaScript `eval`, so it is far narrower than `'unsafe-eval'`. The policy also sets `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'self'`, and `form-action 'self'`. diff --git a/public/openapi/selfhosted-artifacts.yaml b/public/openapi/selfhosted-artifacts.yaml index 8244f3d..f0e245b 100644 --- a/public/openapi/selfhosted-artifacts.yaml +++ b/public/openapi/selfhosted-artifacts.yaml @@ -4,6 +4,11 @@ info: description: | Optional SQLite-backed API for storing agent-render artifact payloads and serving UUID viewer links. Same routes are available when running `npm run selfhosted:dev` or the Docker image. + Deploy behind an authenticated reverse proxy when reads must be private. If `AGENT_RENDER_PASSWORD` + is configured, write operations accept it as a bearer token (or via the server-issued browser cookie) + and return 401 for missing or invalid credentials. Artifact API reads require the same + credentials, so a leaked link alone cannot bypass the page gate. + Artifacts use a sliding TTL controlled by `AGENT_RENDER_TTL_HOURS`, defaulting to 168 hours (seven days). version: "1.0.0" servers: - url: "{origin}" @@ -16,6 +21,8 @@ paths: post: summary: Create artifact operationId: createArtifact + security: + - bearerAuth: [] requestBody: required: true content: @@ -42,6 +49,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + description: Missing or invalid credentials when built-in password auth is enabled + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/artifacts/{id}: parameters: - name: id @@ -69,6 +82,8 @@ paths: put: summary: Update artifact payload operationId: updateArtifact + security: + - bearerAuth: [] requestBody: required: true content: @@ -101,9 +116,17 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + description: Missing or invalid credentials when built-in password auth is enabled + content: + application/json: + schema: + $ref: "#/components/schemas/Error" delete: summary: Delete artifact operationId: deleteArtifact + security: + - bearerAuth: [] responses: "200": description: Deleted @@ -120,10 +143,18 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + description: Missing or invalid credentials when built-in password auth is enabled + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/cleanup: post: summary: Remove all expired artifacts operationId: cleanupExpired + security: + - bearerAuth: [] responses: "200": description: Count of deleted rows @@ -134,7 +165,21 @@ paths: properties: deleted: type: integer + "401": + description: Missing or invalid credentials when built-in password auth is enabled + content: + application/json: + schema: + $ref: "#/components/schemas/Error" components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: | + Set `Authorization: Bearer ` on protected write operations when the + built-in password fallback is enabled. The same-origin browser authentication cookie established by + the HTML sign-in form is also accepted. schemas: Error: type: object diff --git a/selfhosted/db.ts b/selfhosted/db.ts index 81b812d..a7c2ec6 100644 --- a/selfhosted/db.ts +++ b/selfhosted/db.ts @@ -61,7 +61,7 @@ export function closeDb(): void { } /** - * Insert a new artifact with a UUID v4 identifier and 24-hour TTL. + * Insert a new artifact with a UUID v4 identifier and the configured sliding TTL. * * @param payload - The agent-render payload string to store. * @returns The generated UUID and the computed expiration timestamp. diff --git a/selfhosted/server.ts b/selfhosted/server.ts index 15c1cc7..4adea94 100644 --- a/selfhosted/server.ts +++ b/selfhosted/server.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync, readFileSync, createReadStream } from "node:fs"; +import { existsSync, readFileSync, createReadStream, statSync } from "node:fs"; import { stat } from "node:fs/promises"; -import { createHash, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import path from "node:path"; import { createArtifact, @@ -22,6 +22,9 @@ const outputDirectoryWithSeparator = `${outputDirectory}${path.sep}`; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const API_CATALOG_CONTENT_TYPE = 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'; const API_CATALOG_LINK_HEADER = '; rel="api-catalog"; type="application/linkset+json"'; +const authPassword = process.env.AGENT_RENDER_PASSWORD; +const authCookieName = "agent_render_auth"; +const authSalt = randomBytes(32); const contentTypes = new Map([ [".html", "text/html; charset=utf-8"], @@ -320,6 +323,100 @@ function isUuid(value: string): boolean { return UUID_RE.test(value); } +function constantTimeEqual(left: string, right: string): boolean { + const leftDigest = createHmac("sha256", authSalt).update(left).digest(); + const rightDigest = createHmac("sha256", authSalt).update(right).digest(); + return timingSafeEqual(leftDigest, rightDigest); +} + +function expectedAuthCookie(): string { + return createHmac("sha256", authSalt).update(authPassword ?? "").digest("base64url"); +} + +function cookieValue(req: IncomingMessage, name: string): string | null { + for (const part of (req.headers.cookie ?? "").split(";")) { + const separator = part.indexOf("="); + if (separator === -1) continue; + if (part.slice(0, separator).trim() === name) { + return part.slice(separator + 1).trim(); + } + } + return null; +} + +function hasValidCookie(req: IncomingMessage): boolean { + if (authPassword === undefined) return true; + const supplied = cookieValue(req, authCookieName); + return supplied !== null && constantTimeEqual(supplied, expectedAuthCookie()); +} + +function hasValidApiAuth(req: IncomingMessage): boolean { + if (authPassword === undefined || hasValidCookie(req)) return true; + const authorization = req.headers.authorization; + if (!authorization?.startsWith("Bearer ")) return false; + return constantTimeEqual(authorization.slice("Bearer ".length), authPassword); +} + +function safeRedirect(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) { + return "/"; + } + try { + const parsed = new URL(value, "http://localhost"); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return "/"; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function passwordPage(redirect: string, invalid = false): string { + return ` + + +Sign in — agent-render +

Sign in to agent-render

${invalid ? "

Incorrect password.

" : ""} +
+ + + +
+`; +} + +function requireBrowserAuth(req: IncomingMessage, res: ServerResponse, redirect: string): boolean { + if (hasValidCookie(req)) return false; + htmlResponse(res, 401, passwordPage(redirect)); + return true; +} + +function staticHtmlPath(urlPath: string): string | null { + const normalizedPath = urlPath === "/" ? "/index.html" : urlPath; + let filePath = path.resolve(path.join(outputDirectory, normalizedPath)); + if (!filePath.startsWith(outputDirectoryWithSeparator) && filePath !== outputDirectory) { + return null; + } + + try { + if (statSync(filePath).isDirectory()) { + filePath = path.join(filePath, "index.html"); + } + } catch { + if (!path.extname(filePath)) { + filePath = path.join(filePath, "index.html"); + } + } + + return existsSync(filePath) && path.extname(filePath) === ".html" ? filePath : null; +} + /** * Generate a simple error HTML page for expired or missing artifacts. */ @@ -381,13 +478,44 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise if (pathname.startsWith("/api/")) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (method === "OPTIONS") { res.writeHead(204); res.end(); return; } + + // Reads are gated too: an open GET /api/artifacts/:id would let anyone with a link bypass the + // page cookie gate (and, with the wildcard CORS above, let any website read artifacts). + if (!hasValidApiAuth(req)) { + res.setHeader("WWW-Authenticate", "Bearer"); + jsonResponse(res, 401, { error: "Unauthorized." }); + return; + } + } + + if (pathname === "/auth" && method === "POST") { + let form: URLSearchParams; + try { + form = new URLSearchParams(await readBody(req)); + } catch { + htmlResponse(res, 400, passwordPage("/")); + return; + } + const redirect = safeRedirect(form.get("redirect")); + const suppliedPassword = form.get("password") ?? ""; + if (authPassword === undefined || constantTimeEqual(suppliedPassword, authPassword)) { + res.setHeader( + "Set-Cookie", + `${authCookieName}=${expectedAuthCookie()}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=31536000`, + ); + res.writeHead(303, { Location: redirect }); + res.end(); + return; + } + htmlResponse(res, 401, passwordPage(redirect, true)); + return; } // POST /api/artifacts — create @@ -465,6 +593,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise // GET /:uuid — render viewer with stored payload const pathSegment = pathname.slice(1); if (method === "GET" && isUuid(pathSegment)) { + if (requireBrowserAuth(req, res, `${pathname}${url.search}`)) return; const row = getArtifact(pathSegment); if (!row) { htmlResponse(res, 404, errorPage("Artifact not found", "This artifact has expired or does not exist.")); @@ -483,6 +612,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } + if (method === "GET" && staticHtmlPath(pathname)) { + if (requireBrowserAuth(req, res, `${pathname}${url.search}`)) return; + } + // Static file fallback await serveStatic(res, pathname, method); } diff --git a/selfhosted/ttl.ts b/selfhosted/ttl.ts index 0f7c802..7fd45c9 100644 --- a/selfhosted/ttl.ts +++ b/selfhosted/ttl.ts @@ -1,8 +1,24 @@ -/** Default time-to-live duration in milliseconds (24 hours). */ -export const TTL_MS = 24 * 60 * 60 * 1000; +/** Default sliding time-to-live duration in hours (7 days). */ +export const DEFAULT_TTL_HOURS = 7 * 24; + +function configuredTtlHours(value: string | undefined): number { + if (value === undefined) return DEFAULT_TTL_HOURS; + if (!/^[1-9]\d*$/.test(value)) { + throw new Error("AGENT_RENDER_TTL_HOURS must be a positive integer."); + } + + const hours = Number(value); + if (!Number.isSafeInteger(hours) || !Number.isSafeInteger(hours * 60 * 60 * 1000)) { + throw new Error("AGENT_RENDER_TTL_HOURS must be a positive integer."); + } + return hours; +} + +/** Configured time-to-live duration in milliseconds. */ +export const TTL_MS = configuredTtlHours(process.env.AGENT_RENDER_TTL_HOURS) * 60 * 60 * 1000; /** - * Compute an ISO 8601 expiration timestamp 24 hours from now. + * Compute an ISO 8601 expiration timestamp one configured TTL from now. * * Used when creating or refreshing artifact TTL in the database. * Returns a UTC datetime string suitable for SQLite text comparison. diff --git a/skills/selfhosted-agent-render/SKILL.md b/skills/selfhosted-agent-render/SKILL.md index 316d078..8bc15e6 100644 --- a/skills/selfhosted-agent-render/SKILL.md +++ b/skills/selfhosted-agent-render/SKILL.md @@ -1,6 +1,6 @@ --- name: selfhosted-agent-render -description: Create and manage agent-render artifacts via a self-hosted UUID-based server. Use when an agent needs public/share-friendly rendered artifacts through short UUID links instead of fragment-encoded URLs. Ideal for public/social sharing, corporate proxy/link-scanning environments, payloads that exceed the ~8 KB fragment budget, platforms that mangle long URLs, or when the agent and viewer run on the same machine. Supports markdown, code, diffs, CSV, and JSON — same artifact kinds as the fragment-based product (the server stores the payload string after a length/non-empty check; full envelope validation happens client-side when the viewer renders). The self-hosted server stores payloads in SQLite with a 24-hour sliding TTL. +description: Create and manage agent-render artifacts via a self-hosted UUID-based server. Use when an agent needs public/share-friendly rendered artifacts through short UUID links instead of fragment-encoded URLs. Ideal for public/social sharing, corporate proxy/link-scanning environments, payloads that exceed the ~8 KB fragment budget, platforms that mangle long URLs, or when the agent and viewer run on the same machine. Supports markdown, code, diffs, CSV, and JSON — same artifact kinds as the fragment-based product (the server stores the payload string after a length/non-empty check; full envelope validation happens client-side when the viewer renders). The self-hosted server stores payloads in SQLite with a configurable sliding TTL that defaults to seven days. --- # Self-Hosted Agent Render @@ -29,11 +29,20 @@ The self-hosted server exposes a simple REST API. Discovery: `GET /.well-known/api-catalog` returns RFC 9727 `application/linkset+json` with an `item` link to `/api/artifacts` and `service-desc` metadata pointing to the OpenAPI file for this optional self-hosted API. +When `AGENT_RENDER_PASSWORD` is set, send it as a bearer token on API write requests: + +```http +Authorization: Bearer +``` + +The same-origin browser authentication cookie is also accepted. Missing or invalid credentials on a protected route return `401` with a bearer challenge. Artifact API reads are gated with the same credentials, so agents fetching stored artifacts need the bearer header too. + ### Create an artifact ```http POST /api/artifacts Content-Type: application/json +Authorization: Bearer { "payload": "p" @@ -70,13 +79,14 @@ Response (`200`): } ``` -Each successful read extends the TTL by 24 hours. +Each successful read extends the TTL by the configured duration (seven days by default). ### Update an artifact ```http PUT /api/artifacts/:id Content-Type: application/json +Authorization: Bearer { "payload": "p" @@ -87,12 +97,14 @@ Content-Type: application/json ```http DELETE /api/artifacts/:id +Authorization: Bearer ``` ### Cleanup expired ```http POST /api/cleanup +Authorization: Bearer ``` Response: `{ "deleted": 5 }` @@ -263,8 +275,9 @@ p ## TTL behavior -- Artifacts expire 24 hours after creation -- Every successful read (API or viewer) extends the expiry by another 24 hours +- Artifacts expire seven days after creation by default +- Set `AGENT_RENDER_TTL_HOURS` to a positive integer to change the sliding TTL +- Every successful read (API or viewer) extends the expiry by the configured duration - Expired artifacts return 404 and are lazily cleaned up on access - The server also sweeps expired rows automatically on startup and once an hour - Run `POST /api/cleanup` to batch-remove all expired artifacts on demand @@ -283,7 +296,7 @@ npm run build npm run selfhosted:dev ``` -The server runs on port 3000 by default. Set `PORT` and `DB_PATH` environment variables to customize. +The server runs on port 3000 by default. Set `PORT` and `DB_PATH` to customize it. `AGENT_RENDER_TTL_HOURS` controls the sliding TTL and defaults to `168`; `AGENT_RENDER_PASSWORD` enables the built-in shared-secret auth fallback. ### Docker Compose @@ -324,15 +337,18 @@ pm2 start selfhosted/dist/server.js --name agent-render ## Auth and access control -The self-hosted server does not include built-in authentication. By default, anyone who can reach the server can create, read, and delete artifacts. +Prefer your own reverse proxy or identity-aware access layer when authentication is required. It can protect every route and provide stronger policy, SSO, auditing, and secret management. The built-in `AGENT_RENDER_PASSWORD` option is a fallback for small or local deployments. -Options for protecting the server: +When `AGENT_RENDER_PASSWORD` is unset, anyone who can reach the server can create, read, update, and delete artifacts. When it is set: -### Public access +- `POST`, `PUT`, and `DELETE` API requests require `Authorization: Bearer ` or the server-issued authentication cookie. +- Stored UUID viewer pages and static HTML pages require the authentication cookie. Without it, the server returns a `401` sign-in page; submitting the password form to `/auth` sets an `HttpOnly`, `Secure`, `SameSite=Lax` cookie and redirects back to the requested page. +- `GET /api/artifacts/:id` requires the same credentials; static assets, API discovery, and `GET /health` remain open. +- Protected API requests without valid credentials return `401 Unauthorized` with a bearer challenge. -If you want the server to be publicly accessible, no additional configuration is needed. This is the recommended setup for non-sensitive artifacts that need short, share-friendly public URLs. +The built-in password therefore protects writes, browser entry points, and artifact API reads with one shared secret. Put the deployment behind a reverse proxy or identity-aware proxy when you need per-user access instead of a shared password. -### Cloudflare Tunnel + Zero Trust (recommended for remote access) +### Cloudflare Tunnel + Zero Trust For exposing the server securely to the internet: @@ -344,7 +360,7 @@ For exposing the server securely to the internet: This gives you authentication, access logs, and DDoS protection without modifying the application. -### Reverse proxy with auth +### Other reverse proxies Place the server behind nginx, Caddy, or Traefik with HTTP basic auth, OAuth2 proxy, or mTLS. @@ -367,12 +383,13 @@ PAYLOAD=$(echo -n '{"v":1,"codec":"plain","artifacts":[{"id":"demo","kind":"mark curl -s -X POST http://localhost:3000/api/artifacts \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $AGENT_RENDER_PASSWORD" \ -d "{\"payload\": \"p$PAYLOAD\"}" ``` ## Cleanup guidance -Artifacts auto-expire after 24 hours of inactivity, and the server sweeps expired rows on startup and hourly, so storage reclaims itself. For proactive cleanup: +Artifacts auto-expire after seven days of inactivity by default, and the server sweeps expired rows on startup and hourly, so storage reclaims itself. Set `AGENT_RENDER_TTL_HOURS` to a positive integer to choose another duration. For proactive cleanup: - Call `POST /api/cleanup` to remove all expired artifacts immediately - Call `DELETE /api/artifacts/:id` to remove specific artifacts @@ -382,7 +399,8 @@ Artifacts auto-expire after 24 hours of inactivity, and the server sweeps expire - Use self-hosted mode for public sharing, large payloads, corporate-proxy contexts, or agent-driven workflows - Use fragment links for quick, trusted direct shares that fit in the budget - Keep the server on the same machine as the agent for simplicity -- Use Cloudflare Tunnel if you need remote access with authentication +- Put remote deployments behind your existing authenticated reverse proxy +- Use `AGENT_RENDER_PASSWORD` only as a small-deployment fallback - Let TTL handle cleanup for most cases ## Future encrypted short-link mode diff --git a/tests/selfhosted/auth.test.ts b/tests/selfhosted/auth.test.ts new file mode 100644 index 0000000..c881ff7 --- /dev/null +++ b/tests/selfhosted/auth.test.ts @@ -0,0 +1,280 @@ +// @vitest-environment node +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const repoRoot = process.cwd(); +const password = "correct horse battery staple"; + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Could not allocate a test port.")); + }); + }); + }); +} + +function fixture(): { root: string; outDir: string } { + const root = mkdtempSync(path.join(tmpdir(), "agent-render-auth-")); + const outDir = path.join(root, "out"); + mkdirSync(path.join(outDir, "security"), { recursive: true }); + writeFileSync( + path.join(outDir, "index.html"), + "Home", + ); + writeFileSync(path.join(outDir, "security", "index.html"), "Security"); + writeFileSync(path.join(outDir, "app.js"), "globalThis.loaded = true;"); + return { root, outDir }; +} + +function startServer( + port: number, + files: { root: string; outDir: string }, + configuredPassword?: string, +): ChildProcess { + const env: NodeJS.ProcessEnv = { + ...process.env, + PORT: String(port), + HOST: "127.0.0.1", + OUT_DIR: files.outDir, + DB_PATH: path.join(files.root, "agent-render.db"), + }; + if (configuredPassword === undefined) delete env.AGENT_RENDER_PASSWORD; + else env.AGENT_RENDER_PASSWORD = configuredPassword; + + return spawn( + process.execPath, + ["--import", "tsx", path.join(repoRoot, "selfhosted", "server.ts")], + { cwd: repoRoot, env, stdio: "ignore" }, + ); +} + +async function waitForHealth(base: string, child: ChildProcess): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline && child.exitCode === null) { + try { + if ((await fetch(`${base}/health`)).ok) return; + } catch { + // Retry until the child starts listening. + } + await new Promise((resolve) => setTimeout(resolve, 40)); + } + throw new Error("Self-hosted server did not become healthy."); +} + +async function stopServer(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + await new Promise((resolve) => { + child.once("close", () => resolve()); + child.kill(); + }); +} + +async function createArtifact(base: string, authorization: string): Promise { + const response = await fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authorization, + }, + body: JSON.stringify({ payload: "pauth-test" }), + }); + expect(response.status).toBe(201); + return ((await response.json()) as { id: string }).id; +} + +describe("optional self-hosted password gate", () => { + let files: { root: string; outDir: string }; + let child: ChildProcess; + let base: string; + let cookie: string; + + beforeAll(async () => { + files = fixture(); + const port = await freePort(); + base = `http://127.0.0.1:${port}`; + child = startServer(port, files, password); + await waitForHealth(base, child); + + const login = await fetch(`${base}/auth`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password, redirect: "/security?from=login" }), + }); + const setCookie = login.headers.get("set-cookie") ?? ""; + cookie = setCookie.split(";", 1)[0]; + }); + + afterAll(async () => { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + }); + + it("leaves health open but gates GET artifact API reads", async () => { + await expect((await fetch(`${base}/health`)).json()).resolves.toEqual({ status: "ok" }); + const id = await createArtifact(base, `Bearer ${password}`); + + // An open API read would let anyone with a link bypass the page cookie gate. + const unauthenticated = await fetch(`${base}/api/artifacts/${id}`); + expect(unauthenticated.status).toBe(401); + + const withBearer = await fetch(`${base}/api/artifacts/${id}`, { + headers: { Authorization: `Bearer ${password}` }, + }); + expect(withBearer.status).toBe(200); + await expect(withBearer.json()).resolves.toMatchObject({ id, payload: "pauth-test" }); + + const withCookie = await fetch(`${base}/api/artifacts/${id}`, { headers: { cookie } }); + expect(withCookie.status).toBe(200); + }); + + it("rejects missing and incorrect credentials on mutating API routes", async () => { + const missing = await fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payload: "pdenied" }), + }); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toBe("Bearer"); + await expect(missing.json()).resolves.toEqual({ error: "Unauthorized." }); + + const wrong = await fetch(`${base}/api/cleanup`, { + method: "POST", + headers: { Authorization: "Bearer wrong" }, + }); + expect(wrong.status).toBe(401); + }); + + it("accepts bearer auth for create, update, delete, and cleanup", async () => { + const authorization = `Bearer ${password}`; + const id = await createArtifact(base, authorization); + const updated = await fetch(`${base}/api/artifacts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: authorization }, + body: JSON.stringify({ payload: "pupdated" }), + }); + expect(updated.status).toBe(200); + + const deleted = await fetch(`${base}/api/artifacts/${id}`, { + method: "DELETE", + headers: { Authorization: authorization }, + }); + expect(deleted.status).toBe(200); + + const cleaned = await fetch(`${base}/api/cleanup`, { + method: "POST", + headers: { Authorization: authorization }, + }); + expect(cleaned.status).toBe(200); + }); + + it("accepts the auth cookie on mutating API routes and CORS permits Authorization", async () => { + const created = await fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ payload: "pcookie" }), + }); + expect(created.status).toBe(201); + + const preflight = await fetch(`${base}/api/artifacts`, { method: "OPTIONS" }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-headers")).toContain("Authorization"); + }); + + it("serves a password form for UUID and exported HTML pages but leaves assets open", async () => { + const id = await createArtifact(base, `Bearer ${password}`); + const uuid = await fetch(`${base}/${id}`); + expect(uuid.status).toBe(401); + expect(await uuid.text()).toContain('
'); + + const authenticatedUuid = await fetch(`${base}/${id}`, { headers: { Cookie: cookie } }); + expect(authenticatedUuid.status).toBe(200); + expect(await authenticatedUuid.text()).toContain( + 'window.__AGENT_RENDER_PAYLOAD__="pauth-test"', + ); + + const exported = await fetch(`${base}/security?next=%22test%22`); + expect(exported.status).toBe(401); + const form = await exported.text(); + expect(form).toContain("Sign in to agent-render"); + expect(form).toContain("/security?next=%22test%22"); + + const asset = await fetch(`${base}/app.js`); + expect(asset.status).toBe(200); + }); + + it("sets a persistent restart-scoped HMAC cookie and redirects to a safe local path", async () => { + const login = await fetch(`${base}/auth`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password, redirect: "/security?from=login" }), + }); + expect(login.status).toBe(303); + expect(login.headers.get("location")).toBe("/security?from=login"); + const setCookie = login.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain("agent_render_auth="); + expect(setCookie).not.toContain(password); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("Secure"); + expect(setCookie).toContain("SameSite=Lax"); + expect(setCookie).toContain("Path=/"); + expect(setCookie).toContain("Max-Age=31536000"); + + const page = await fetch(`${base}/security`, { headers: { Cookie: cookie } }); + expect(page.status).toBe(200); + expect(await page.text()).toContain("Security"); + }); + + it("rejects a wrong form password and will not redirect off-origin", async () => { + const wrong = await fetch(`${base}/auth`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password: "wrong", redirect: "/security" }), + }); + expect(wrong.status).toBe(401); + expect(await wrong.text()).toContain("Incorrect password."); + + const unsafe = await fetch(`${base}/auth`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password, redirect: "//example.com/stolen" }), + }); + expect(unsafe.status).toBe(303); + expect(unsafe.headers.get("location")).toBe("/"); + }); +}); + +describe("self-hosted server without a password", () => { + it("preserves open browser and mutating API behavior", async () => { + const files = fixture(); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const child = startServer(port, files); + try { + await waitForHealth(base, child); + expect((await fetch(`${base}/`)).status).toBe(200); + const created = await fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payload: "popen" }), + }); + expect(created.status).toBe(201); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/selfhosted/db.test.ts b/tests/selfhosted/db.test.ts index 0b7591f..43d08b8 100644 --- a/tests/selfhosted/db.test.ts +++ b/tests/selfhosted/db.test.ts @@ -57,9 +57,9 @@ describe("getArtifact", () => { const second = getArtifact(id); expect(second).not.toBeNull(); - // After refresh, expires_at should be ~24h from now, much later than the 1s we set + // After refresh, expires_at should be ~7d from now, much later than the 1s we set const expiresMs = new Date(second!.expires_at).getTime(); - expect(expiresMs).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000); + expect(expiresMs).toBeGreaterThan(Date.now() + 6 * 24 * 60 * 60 * 1000); }); it("returns null and deletes expired artifacts", () => { diff --git a/tests/selfhosted/ttl.test.ts b/tests/selfhosted/ttl.test.ts index c32433f..b8f7574 100644 --- a/tests/selfhosted/ttl.test.ts +++ b/tests/selfhosted/ttl.test.ts @@ -1,15 +1,60 @@ // @vitest-environment node -import { describe, it, expect } from "vitest"; -import { TTL_MS, computeExpiresAt, isExpired } from "../../selfhosted/ttl.js"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { afterEach, describe, it, expect, vi } from "vitest"; -describe("TTL_MS", () => { - it("equals 24 hours in milliseconds", () => { - expect(TTL_MS).toBe(86_400_000); +const originalTtlHours = process.env.AGENT_RENDER_TTL_HOURS; + +afterEach(() => { + if (originalTtlHours === undefined) { + delete process.env.AGENT_RENDER_TTL_HOURS; + } else { + process.env.AGENT_RENDER_TTL_HOURS = originalTtlHours; + } + vi.resetModules(); +}); + +describe("TTL configuration", () => { + it("defaults to 7 days", async () => { + delete process.env.AGENT_RENDER_TTL_HOURS; + vi.resetModules(); + const { DEFAULT_TTL_HOURS, TTL_MS } = await import("../../selfhosted/ttl.js"); + expect(DEFAULT_TTL_HOURS).toBe(168); + expect(TTL_MS).toBe(604_800_000); }); + + it("accepts a positive integer hour override", async () => { + process.env.AGENT_RENDER_TTL_HOURS = "12"; + vi.resetModules(); + const { TTL_MS } = await import("../../selfhosted/ttl.js"); + expect(TTL_MS).toBe(43_200_000); + }); + + it.each(["", "0", "-1", "1.5", "hours"])( + "fails startup for invalid AGENT_RENDER_TTL_HOURS=%j", + (value) => { + const modulePath = path.join(process.cwd(), "selfhosted", "ttl.ts"); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--eval", `import(${JSON.stringify(modulePath)})`], + { + env: { ...process.env, AGENT_RENDER_TTL_HOURS: value }, + encoding: "utf8", + }, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "AGENT_RENDER_TTL_HOURS must be a positive integer.", + ); + }, + ); }); describe("computeExpiresAt", () => { - it("returns an ISO string approximately 24h in the future", () => { + it("returns an ISO string approximately one configured TTL in the future", async () => { + delete process.env.AGENT_RENDER_TTL_HOURS; + vi.resetModules(); + const { computeExpiresAt, TTL_MS } = await import("../../selfhosted/ttl.js"); const before = Date.now(); const result = computeExpiresAt(); const after = Date.now(); @@ -21,11 +66,13 @@ describe("computeExpiresAt", () => { }); describe("isExpired", () => { - it("returns true for a past timestamp", () => { + it("returns true for a past timestamp", async () => { + const { isExpired } = await import("../../selfhosted/ttl.js"); expect(isExpired(new Date(Date.now() - 1000).toISOString())).toBe(true); }); - it("returns false for a future timestamp", () => { + it("returns false for a future timestamp", async () => { + const { isExpired } = await import("../../selfhosted/ttl.js"); expect(isExpired(new Date(Date.now() + 60_000).toISOString())).toBe(false); }); }); From 0cb68b4c5a5d50a821b991f784145ef69d82cca2 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:59:40 -0400 Subject: [PATCH 03/32] cli: @agent-render/cli workspace with create command Coding agents previously had no programmatic interface: they either hand-rolled codec recipes from the 380-line skill (token-expensive, and arx4 is not hand-rollable) or curled the REST API by hand. The payload also had to pass through the agent's context to become a link. agent-render create [files...] reads content from files or stdin, builds a payload envelope, and either POSTs to a configured self-hosted instance (UUID URL out) or encodes a fragment locally with the full codec ladder including arx4. Config precedence is flags, then AGENT_RENDER_INSTANCE_URL/AGENT_RENDER_TOKEN, then the XDG config file. Output formats cover url, markdown, discord (with the 2000-char warning), slack, plain, and --json. The bundle embeds the arx dictionaries and priors so offline Node execution selects arx4; published output has no repo-internal paths. Co-Authored-By: Claude Fable 5 --- cli/build.mjs | 27 +++++++ cli/package.json | 28 +++++++ cli/src/cli.ts | 156 +++++++++++++++++++++++++++++++++++++ cli/src/config.ts | 91 ++++++++++++++++++++++ cli/src/encoding.ts | 55 +++++++++++++ cli/src/envelope.ts | 72 +++++++++++++++++ cli/src/format.ts | 36 +++++++++ cli/src/index.ts | 7 ++ cli/src/instance.ts | 56 +++++++++++++ cli/src/kind.ts | 61 +++++++++++++++ cli/tests/config.test.ts | 54 +++++++++++++ cli/tests/envelope.test.ts | 30 +++++++ cli/tests/format.test.ts | 20 +++++ cli/tests/fragment.test.ts | 33 ++++++++ cli/tests/instance.test.ts | 73 +++++++++++++++++ cli/tests/kind.test.ts | 21 +++++ cli/tsconfig.json | 14 ++++ cli/vitest.config.ts | 15 ++++ package.json | 3 + 19 files changed, 852 insertions(+) create mode 100644 cli/build.mjs create mode 100644 cli/package.json create mode 100644 cli/src/cli.ts create mode 100644 cli/src/config.ts create mode 100644 cli/src/encoding.ts create mode 100644 cli/src/envelope.ts create mode 100644 cli/src/format.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/instance.ts create mode 100644 cli/src/kind.ts create mode 100644 cli/tests/config.test.ts create mode 100644 cli/tests/envelope.test.ts create mode 100644 cli/tests/format.test.ts create mode 100644 cli/tests/fragment.test.ts create mode 100644 cli/tests/instance.test.ts create mode 100644 cli/tests/kind.test.ts create mode 100644 cli/tsconfig.json create mode 100644 cli/vitest.config.ts diff --git a/cli/build.mjs b/cli/build.mjs new file mode 100644 index 0000000..72bc369 --- /dev/null +++ b/cli/build.mjs @@ -0,0 +1,27 @@ +import { build } from "esbuild"; +import { chmod, copyFile, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +await rm("dist", { recursive: true, force: true }); + +await build({ + entryPoints: ["src/index.ts"], + outfile: "dist/index.cjs", + bundle: true, + platform: "node", + format: "cjs", + target: "node20", + sourcemap: true, + banner: { js: "#!/usr/bin/env node" }, + loader: { ".wasm": "file" }, + alias: { + "@": fileURLToPath(new URL("../src", import.meta.url)), + "brotli-wasm": fileURLToPath(new URL("../node_modules/brotli-wasm/index.node.js", import.meta.url)), + }, +}); + +await copyFile( + fileURLToPath(new URL("../node_modules/brotli-wasm/pkg.node/brotli_wasm_bg.wasm", import.meta.url)), + "dist/brotli_wasm_bg.wasm", +); +await chmod("dist/index.cjs", 0o755); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..415abdb --- /dev/null +++ b/cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@agent-render/cli", + "version": "0.1.0", + "description": "Create agent-render artifact links from the command line.", + "license": "MIT", + "type": "module", + "bin": { + "agent-render": "./dist/index.cjs" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "node build.mjs", + "prepack": "npm run build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.8.2", + "vitest": "^4.1.9" + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..fe170b5 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,156 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { stdin as defaultStdin } from "node:process"; +import { getConfigValue, resolveConfig, setConfigValue } from "./config"; +import { buildPayloadEnvelope, type ArtifactInput } from "./envelope"; +import { assertFragmentBudget, createFragmentUrl, encodePayloadEnvelope } from "./encoding"; +import { formatArtifactOutput, type OutputFormat } from "./format"; +import { createInstanceArtifact } from "./instance"; +import type { RequestedKind } from "./kind"; + +type Mode = "auto" | "instance" | "fragment"; + +type CreateOptions = { + files: string[]; + kind: RequestedKind; + title?: string; + mode: Mode; + format: OutputFormat; + stdin: boolean; + json: boolean; + instanceUrl?: string; + token?: string; +}; + +const KINDS = new Set(["auto", "markdown", "code", "diff", "csv", "json"]); +const MODES = new Set(["auto", "instance", "fragment"]); +const FORMATS = new Set(["url", "markdown", "discord", "slack", "plain"]); +const DEFAULT_VIEWER_URL = "https://agent-render.com/"; + +function requireOptionValue(args: string[], index: number, option: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value.`); + return value; +} + +function parseChoice(value: string, choices: Set, option: string): T { + if (!choices.has(value as T)) { + throw new Error(`Invalid ${option} value "${value}". Expected one of: ${[...choices].join(", ")}.`); + } + return value as T; +} + +function parseCreateOptions(args: string[]): CreateOptions { + const options: CreateOptions = { + files: [], + kind: "auto", + mode: "auto", + format: "url", + stdin: false, + json: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (!arg.startsWith("--")) { + options.files.push(arg); + continue; + } + if (arg === "--stdin") options.stdin = true; + else if (arg === "--json") options.json = true; + else if (arg === "--kind") options.kind = parseChoice(requireOptionValue(args, index++, arg), KINDS, arg); + else if (arg === "--title") options.title = requireOptionValue(args, index++, arg); + else if (arg === "--mode") options.mode = parseChoice(requireOptionValue(args, index++, arg), MODES, arg); + else if (arg === "--format") options.format = parseChoice(requireOptionValue(args, index++, arg), FORMATS, arg); + else if (arg === "--instance-url") options.instanceUrl = requireOptionValue(args, index++, arg); + else if (arg === "--token") options.token = requireOptionValue(args, index++, arg); + else throw new Error(`Unknown option "${arg}".`); + } + + if (options.stdin && options.files.length > 0) throw new Error("--stdin cannot be combined with file paths."); + if (!options.stdin && options.files.length === 0) throw new Error("Provide at least one file or use --stdin."); + if (options.stdin && options.kind === "auto") throw new Error("--kind is required with --stdin."); + return options; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of defaultStdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +async function readInputs(options: CreateOptions): Promise { + if (options.stdin) { + return [{ filename: options.title?.trim() || "stdin", content: await readStdin() }]; + } + return Promise.all(options.files.map(async (filename) => ({ + filename, + content: await readFile(filename, "utf8"), + }))); +} + +function getOutputLabel(options: CreateOptions, inputs: ArtifactInput[]): string { + return options.title?.trim() || (inputs.length === 1 ? path.basename(inputs[0]!.filename) : `${inputs.length} artifacts`); +} + +async function runCreate(args: string[]): Promise { + const options = parseCreateOptions(args); + const inputs = await readInputs(options); + const envelope = buildPayloadEnvelope(inputs, options.kind, options.title); + const config = await resolveConfig({ instanceUrl: options.instanceUrl, token: options.token }); + const mode: Exclude = options.mode === "auto" + ? (config.instanceUrl ? "instance" : "fragment") + : options.mode; + const label = getOutputLabel(options, inputs); + let url: string; + let markdownUrl: string; + + if (mode === "instance") { + if (!config.instanceUrl) throw new Error("Instance mode requires INSTANCE_URL configuration."); + url = await createInstanceArtifact(envelope, config.instanceUrl, config.token); + markdownUrl = url; + } else { + const encoded = await encodePayloadEnvelope(envelope); + assertFragmentBudget(encoded.fragmentBody); + url = createFragmentUrl(DEFAULT_VIEWER_URL, encoded.fragmentBody); + markdownUrl = createFragmentUrl(DEFAULT_VIEWER_URL, encoded.transportFragmentBody); + } + + const formatted = formatArtifactOutput(options.format, label, url, markdownUrl); + if (formatted.warning) process.stderr.write(`${formatted.warning}\n`); + if (options.json) { + const bytes = inputs.reduce((total, input) => total + Buffer.byteLength(input.content), 0); + process.stdout.write(`${JSON.stringify({ url, mode, bytes, warning: formatted.warning })}\n`); + } else { + process.stdout.write(`${formatted.text}\n`); + } +} + +async function runConfig(args: string[]): Promise { + const [operation, key, value, ...rest] = args; + if (rest.length > 0 || !operation || !key) { + throw new Error("Usage: agent-render config set KEY VALUE | agent-render config get KEY"); + } + if (operation === "set") { + if (value === undefined) throw new Error("Usage: agent-render config set KEY VALUE"); + const configPath = await setConfigValue(key, value); + process.stderr.write(`Updated ${configPath}\n`); + return; + } + if (operation === "get") { + if (value !== undefined) throw new Error("Usage: agent-render config get KEY"); + const stored = await getConfigValue(key); + if (stored === undefined) throw new Error(`Config key "${key}" is not set.`); + process.stdout.write(`${stored}\n`); + return; + } + throw new Error(`Unknown config operation "${operation}". Expected set or get.`); +} + +/** Runs the agent-render command-line interface. */ +export async function runCli(args: string[]): Promise { + const [command, ...rest] = args; + if (command === "create") return runCreate(rest); + if (command === "config") return runConfig(rest); + throw new Error("Usage: agent-render create [files...] [options] | agent-render config set|get ..."); +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..481abd5 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,91 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type ConfigKey = "INSTANCE_URL" | "TOKEN"; + +export type StoredConfig = { + instanceUrl?: string; + token?: string; +}; + +export type ResolvedConfig = StoredConfig & { + configPath: string; +}; + +function normalizeConfigKey(key: string): ConfigKey { + const normalized = key.replace(/[-_]/g, "").toLowerCase(); + if (normalized === "instanceurl") return "INSTANCE_URL"; + if (normalized === "token") return "TOKEN"; + throw new Error(`Unknown config key "${key}". Expected INSTANCE_URL or TOKEN.`); +} + +/** Resolves the XDG-compatible agent-render config file path. */ +export function getConfigPath(env: NodeJS.ProcessEnv = process.env): string { + const configHome = env.XDG_CONFIG_HOME?.trim(); + const home = env.HOME?.trim() || os.homedir(); + return path.join(configHome || path.join(home, ".config"), "agent-render", "config.json"); +} + +/** Reads stored CLI configuration, treating a missing file as empty configuration. */ +export async function readStoredConfig(env: NodeJS.ProcessEnv = process.env): Promise { + const configPath = getConfigPath(env); + let contents: string; + try { + contents = await readFile(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } + + const parsed: unknown = JSON.parse(contents); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Config file ${configPath} must contain a JSON object.`); + } + + const record = parsed as Record; + return { + instanceUrl: typeof record.instanceUrl === "string" ? record.instanceUrl : undefined, + token: typeof record.token === "string" ? record.token : undefined, + }; +} + +/** Resolves CLI configuration with flags taking precedence over environment and file values. */ +export async function resolveConfig( + flags: StoredConfig = {}, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const stored = await readStoredConfig(env); + return { + instanceUrl: flags.instanceUrl ?? env.AGENT_RENDER_INSTANCE_URL ?? stored.instanceUrl, + token: flags.token ?? env.AGENT_RENDER_TOKEN ?? stored.token, + configPath: getConfigPath(env), + }; +} + +/** Stores one supported CLI configuration value. */ +export async function setConfigValue( + keyInput: string, + value: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const key = normalizeConfigKey(keyInput); + const configPath = getConfigPath(env); + const config = await readStoredConfig(env); + if (key === "INSTANCE_URL") config.instanceUrl = value; + else config.token = value; + + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + return configPath; +} + +/** Reads one supported value directly from the config file. */ +export async function getConfigValue( + keyInput: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const key = normalizeConfigKey(keyInput); + const config = await readStoredConfig(env); + return key === "INSTANCE_URL" ? config.instanceUrl : config.token; +} diff --git a/cli/src/encoding.ts b/cli/src/encoding.ts new file mode 100644 index 0000000..a029246 --- /dev/null +++ b/cli/src/encoding.ts @@ -0,0 +1,55 @@ +import arxDictionaryJson from "../../public/arx-dictionary.json"; +import arx2DictionaryJson from "../../public/arx2-dictionary.json"; +import arx4PriorsJson from "../../public/arx4-priors.json"; +import { + loadArx2OverlayDictionarySync, + loadArxDictionarySync, + type ArxDictionary, +} from "../../src/lib/payload/arx-codec"; +import { + loadArx4PriorsSync, + type Arx4Priors, +} from "../../src/lib/payload/arx4-codec"; +import { + encodeEnvelopeSurfacesAsync, + getVisibleFragmentLength, +} from "../../src/lib/payload/fragment"; +import { MAX_FRAGMENT_LENGTH, type PayloadEnvelope } from "../../src/lib/payload/schema"; + +let codecsInitialized = false; + +function initializeCodecs(): void { + if (codecsInitialized) return; + loadArxDictionarySync(arxDictionaryJson as ArxDictionary); + loadArx2OverlayDictionarySync(arx2DictionaryJson as ArxDictionary); + loadArx4PriorsSync(arx4PriorsJson as Arx4Priors); + codecsInitialized = true; +} + +export type EncodedEnvelope = { + fragmentBody: string; + transportFragmentBody: string; +}; + +/** Encodes an envelope with the full async codec ladder and embedded shipped codec assets. */ +export async function encodePayloadEnvelope(envelope: PayloadEnvelope): Promise { + initializeCodecs(); + return encodeEnvelopeSurfacesAsync(envelope); +} + +/** Joins a fragment body to a viewer base URL without percent-encoding its Unicode wire form. */ +export function createFragmentUrl(baseUrl: string, fragmentBody: string): string { + const base = new URL(baseUrl); + base.hash = ""; + return `${base.toString()}#${fragmentBody}`; +} + +/** Enforces the public fragment transport budget for a generated fragment. */ +export function assertFragmentBudget(fragmentBody: string): void { + const length = getVisibleFragmentLength(fragmentBody); + if (length > MAX_FRAGMENT_LENGTH) { + throw new Error( + `This link needs ${length.toLocaleString()} fragment characters, which is over the ${MAX_FRAGMENT_LENGTH.toLocaleString()} character limit.`, + ); + } +} diff --git a/cli/src/envelope.ts b/cli/src/envelope.ts new file mode 100644 index 0000000..7229e93 --- /dev/null +++ b/cli/src/envelope.ts @@ -0,0 +1,72 @@ +import path from "node:path"; +import type { ArtifactPayload, PayloadEnvelope } from "../../src/lib/payload/schema"; +import { normalizeEnvelope } from "../../src/lib/payload/envelope"; +import { detectArtifactKind, type RequestedKind } from "./kind"; + +export type ArtifactInput = { + filename: string; + content: string; +}; + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "artifact"; +} + +function buildArtifact( + input: ArtifactInput, + requestedKind: RequestedKind, + id: string, + titleOverride?: string, +): ArtifactPayload { + const detected = detectArtifactKind(input.filename, requestedKind); + const filename = path.basename(input.filename); + const title = titleOverride?.trim() || filename; + if (detected.kind === "diff") { + return { id, kind: "diff", title, filename, patch: input.content, view: "unified" }; + } + if (detected.kind === "code") { + return { + id, + kind: "code", + title, + filename, + content: input.content, + language: detected.language, + }; + } + return { id, kind: detected.kind, title, filename, content: input.content }; +} + +/** Builds and validates one payload envelope from one or more artifact inputs. */ +export function buildPayloadEnvelope( + inputs: ArtifactInput[], + requestedKind: RequestedKind, + title?: string, +): PayloadEnvelope { + const idCounts = new Map(); + const artifacts = inputs.map((input) => { + const baseId = slugify(path.basename(input.filename, path.extname(input.filename))); + const count = (idCounts.get(baseId) ?? 0) + 1; + idCounts.set(baseId, count); + return buildArtifact( + input, + requestedKind, + count === 1 ? baseId : `${baseId}-${count}`, + inputs.length === 1 ? title : undefined, + ); + }); + + const candidate: PayloadEnvelope = { + v: 1, + codec: "plain", + title: title?.trim() || (artifacts.length === 1 ? artifacts[0]?.title : undefined), + activeArtifactId: artifacts[0]?.id, + artifacts, + }; + const normalized = normalizeEnvelope(candidate); + if (!normalized.ok) throw new Error(normalized.message); + return normalized.envelope; +} diff --git a/cli/src/format.ts b/cli/src/format.ts new file mode 100644 index 0000000..25c6960 --- /dev/null +++ b/cli/src/format.ts @@ -0,0 +1,36 @@ +import { + buildMarkdownLinkShareInfo, + formatMarkdownLink, +} from "../../src/lib/markdown-link"; + +export type OutputFormat = "url" | "markdown" | "discord" | "slack" | "plain"; + +export type FormattedOutput = { + text: string; + warning: string | null; +}; + +function escapeSlack(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\|/g, "|"); +} + +/** Formats one artifact URL for the requested chat or plain-text surface. */ +export function formatArtifactOutput( + format: OutputFormat, + label: string, + url: string, + markdownUrl: string = url, +): FormattedOutput { + if (format === "markdown") return { text: formatMarkdownLink(label, markdownUrl), warning: null }; + if (format === "discord") { + const share = buildMarkdownLinkShareInfo(label, markdownUrl); + return { text: share.markdownLink, warning: share.discordWarning }; + } + if (format === "slack") return { text: `<${url}|${escapeSlack(label)}>`, warning: null }; + if (format === "plain") return { text: `${label}: ${url}`, warning: null }; + return { text: url, warning: null }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..7c66756 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,7 @@ +import { runCli } from "./cli"; + +runCli(process.argv.slice(2)).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`agent-render: ${message}\n`); + process.exitCode = 1; +}); diff --git a/cli/src/instance.ts b/cli/src/instance.ts new file mode 100644 index 0000000..6df60ae --- /dev/null +++ b/cli/src/instance.ts @@ -0,0 +1,56 @@ +import type { PayloadEnvelope } from "../../src/lib/payload/schema"; +import { encodePayloadEnvelope } from "./encoding"; + +type ArtifactCreated = { + id: string; +}; + +function instanceUrl(baseUrl: string, suffix: string): string { + const base = new URL(baseUrl); + base.search = ""; + base.hash = ""; + const rootPath = base.pathname.replace(/\/+$/, ""); + base.pathname = `${rootPath}/${suffix.replace(/^\/+/, "")}`; + return base.toString(); +} + +function parseArtifactCreated(value: unknown): ArtifactCreated { + if (typeof value !== "object" || value === null || typeof (value as { id?: unknown }).id !== "string") { + throw new Error("The agent-render instance returned an invalid create response."); + } + return { id: (value as { id: string }).id }; +} + +/** Creates an artifact on a configured self-hosted instance and returns its UUID viewer URL. */ +export async function createInstanceArtifact( + envelope: PayloadEnvelope, + baseUrl: string, + token?: string, +): Promise { + const encoded = await encodePayloadEnvelope(envelope); + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(instanceUrl(baseUrl, "api/artifacts"), { + method: "POST", + headers, + body: JSON.stringify({ payload: encoded.fragmentBody }), + }); + + const responseText = await response.text(); + if (!response.ok) { + let detail = responseText; + try { + const parsed: unknown = JSON.parse(responseText); + if (typeof parsed === "object" && parsed !== null && typeof (parsed as { error?: unknown }).error === "string") { + detail = (parsed as { error: string }).error; + } + } catch { + // Keep the response body as the diagnostic. + } + throw new Error(`Instance create failed (${response.status}): ${detail || response.statusText}`); + } + + const created = parseArtifactCreated(JSON.parse(responseText) as unknown); + return instanceUrl(baseUrl, created.id); +} diff --git a/cli/src/kind.ts b/cli/src/kind.ts new file mode 100644 index 0000000..1fc459c --- /dev/null +++ b/cli/src/kind.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import type { ArtifactKind } from "../../src/lib/payload/schema"; + +export type RequestedKind = ArtifactKind | "auto"; + +export type DetectedKind = { + kind: ArtifactKind; + language?: string; +}; + +const kindByExtension = new Map([ + [".md", "markdown"], + [".markdown", "markdown"], + [".diff", "diff"], + [".patch", "diff"], + [".csv", "csv"], + [".json", "json"], +]); + +const languageByExtension = new Map([ + [".c", "c"], + [".cc", "cpp"], + [".cpp", "cpp"], + [".css", "css"], + [".go", "go"], + [".html", "html"], + [".java", "java"], + [".js", "javascript"], + [".jsx", "jsx"], + [".py", "python"], + [".rb", "ruby"], + [".rs", "rust"], + [".sh", "shell"], + [".sql", "sql"], + [".ts", "typescript"], + [".tsx", "tsx"], + [".xml", "xml"], + [".yaml", "yaml"], + [".yml", "yaml"], +]); + +/** Detects an artifact kind and optional code language from a filename. */ +export function detectArtifactKind(filename: string, requested: RequestedKind = "auto"): DetectedKind { + const extension = path.extname(filename).toLowerCase(); + + if (requested !== "auto") { + return requested === "code" + ? { kind: requested, language: languageByExtension.get(extension) ?? (extension.slice(1) || undefined) } + : { kind: requested }; + } + + const kind = kindByExtension.get(extension); + if (kind) { + return { kind }; + } + + return { + kind: "code", + language: languageByExtension.get(extension) ?? (extension.slice(1) || undefined), + }; +} diff --git a/cli/tests/config.test.ts b/cli/tests/config.test.ts new file mode 100644 index 0000000..fff69d6 --- /dev/null +++ b/cli/tests/config.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getConfigPath, + getConfigValue, + resolveConfig, + setConfigValue, +} from "../src/config"; + +const temporaryDirectories: string[] = []; + +async function temporaryHome(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "agent-render-cli-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("CLI config", () => { + it("uses HOME when XDG_CONFIG_HOME is absent", async () => { + const home = await temporaryHome(); + const env = { HOME: home }; + await setConfigValue("INSTANCE_URL", "https://stored.example/base", env); + + expect(getConfigPath(env)).toBe(path.join(home, ".config", "agent-render", "config.json")); + expect(await getConfigValue("instance-url", env)).toBe("https://stored.example/base"); + const stored = JSON.parse(await readFile(getConfigPath(env), "utf8")) as unknown; + expect(stored).toEqual({ instanceUrl: "https://stored.example/base" }); + }); + + it("resolves flags over environment over stored values", async () => { + const home = await temporaryHome(); + const fileEnv = { HOME: home }; + await setConfigValue("INSTANCE_URL", "https://stored.example", fileEnv); + await setConfigValue("TOKEN", "stored-token", fileEnv); + + const environment = { + HOME: home, + AGENT_RENDER_INSTANCE_URL: "https://env.example", + AGENT_RENDER_TOKEN: "env-token", + }; + expect(await resolveConfig({}, environment)).toMatchObject({ + instanceUrl: "https://env.example", + token: "env-token", + }); + expect(await resolveConfig({ instanceUrl: "https://flag.example", token: "flag-token" }, environment)) + .toMatchObject({ instanceUrl: "https://flag.example", token: "flag-token" }); + }); +}); diff --git a/cli/tests/envelope.test.ts b/cli/tests/envelope.test.ts new file mode 100644 index 0000000..33a0670 --- /dev/null +++ b/cli/tests/envelope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { buildPayloadEnvelope } from "../src/envelope"; + +describe("buildPayloadEnvelope", () => { + it("combines multiple files into one bundle with unique ids", () => { + const envelope = buildPayloadEnvelope([ + { filename: "one/report.md", content: "# One" }, + { filename: "two/report.md", content: "# Two" }, + ], "auto", "Reports"); + + expect(envelope.title).toBe("Reports"); + expect(envelope.activeArtifactId).toBe("report"); + expect(envelope.artifacts).toHaveLength(2); + expect(envelope.artifacts.map((artifact) => artifact.id)).toEqual(["report", "report-2"]); + expect(envelope.artifacts.map((artifact) => artifact.filename)).toEqual(["report.md", "report.md"]); + }); + + it("uses --title for a single artifact without leaking its local path", () => { + const envelope = buildPayloadEnvelope( + [{ filename: "/private/work/report.md", content: "# Report" }], + "auto", + "Quarterly report", + ); + + expect(envelope.artifacts[0]).toMatchObject({ + title: "Quarterly report", + filename: "report.md", + }); + }); +}); diff --git a/cli/tests/format.test.ts b/cli/tests/format.test.ts new file mode 100644 index 0000000..be73fd4 --- /dev/null +++ b/cli/tests/format.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { DISCORD_MESSAGE_MAX_LENGTH } from "../../src/lib/markdown-link"; +import { formatArtifactOutput } from "../src/format"; + +describe("formatArtifactOutput", () => { + it("formats markdown, Slack, plain text, and bare URLs", () => { + const url = "https://agent-render.com/#payload"; + expect(formatArtifactOutput("url", "Report", url).text).toBe(url); + expect(formatArtifactOutput("markdown", "Report", url).text).toBe(`[Report](${url})`); + expect(formatArtifactOutput("slack", "A | B", url).text).toBe(`<${url}|A | B>`); + expect(formatArtifactOutput("plain", "Report", url).text).toBe(`Report: ${url}`); + }); + + it("uses the existing Discord warning contract", () => { + const oversizedUrl = `https://agent-render.com/#${"x".repeat(DISCORD_MESSAGE_MAX_LENGTH)}`; + const result = formatArtifactOutput("discord", "Report", oversizedUrl); + expect(result.text).toBe(`[Report](${oversizedUrl})`); + expect(result.warning).toContain("exceeds Discord"); + }); +}); diff --git a/cli/tests/fragment.test.ts b/cli/tests/fragment.test.ts new file mode 100644 index 0000000..f5a58c7 --- /dev/null +++ b/cli/tests/fragment.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { decodeFragmentAsync } from "../../src/lib/payload/fragment"; +import { buildPayloadEnvelope } from "../src/envelope"; +import { + assertFragmentBudget, + createFragmentUrl, + encodePayloadEnvelope, +} from "../src/encoding"; + +describe("fragment mode", () => { + it("creates a decodable fragment URL for a small markdown artifact", async () => { + const envelope = buildPayloadEnvelope( + [{ filename: "sample.md", content: "# Hello\n\nFrom the CLI.\n" }], + "auto", + "CLI sample", + ); + const encoded = await encodePayloadEnvelope(envelope); + + assertFragmentBudget(encoded.fragmentBody); + const url = createFragmentUrl("https://agent-render.com/", encoded.fragmentBody); + const decoded = await decodeFragmentAsync(new URL(url).hash); + + expect(url).toMatch(/^https:\/\/agent-render\.com\/#[pldabce]/u); + expect(decoded.ok).toBe(true); + if (decoded.ok) { + expect(decoded.envelope.title).toBe("CLI sample"); + expect(decoded.envelope.artifacts[0]).toMatchObject({ + kind: "markdown", + content: "# Hello\n\nFrom the CLI.\n", + }); + } + }); +}); diff --git a/cli/tests/instance.test.ts b/cli/tests/instance.test.ts new file mode 100644 index 0000000..e8a98cb --- /dev/null +++ b/cli/tests/instance.test.ts @@ -0,0 +1,73 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { decodeFragmentAsync } from "../../src/lib/payload/fragment"; +import { buildPayloadEnvelope } from "../src/envelope"; +import { createInstanceArtifact } from "../src/instance"; + +const servers: ReturnType[] = []; + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async (server) => { + server.close(); + await once(server, "close"); + })); +}); + +describe("instance mode", () => { + it("posts the encoded envelope with bearer auth and returns the UUID URL", async () => { + let capturedRequest: { + method?: string; + url?: string; + authorization?: string; + payload?: string; + } = {}; + const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + const body = JSON.parse(await readRequestBody(request)) as { payload?: unknown }; + capturedRequest = { + method: request.method, + url: request.url, + authorization: request.headers.authorization, + payload: typeof body.payload === "string" ? body.payload : undefined, + }; + response.writeHead(201, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ + id: "123e4567-e89b-42d3-a456-426614174000", + expires_at: "2026-08-01T00:00:00.000Z", + })); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (typeof address !== "object" || address === null) throw new Error("Mock server did not expose a TCP address."); + + const envelope = buildPayloadEnvelope( + [{ filename: "sample.json", content: "{\"ready\":true}\n" }], + "auto", + ); + const baseUrl = `http://127.0.0.1:${address.port}/render`; + const result = await createInstanceArtifact(envelope, baseUrl, "test-token"); + + expect(result).toBe(`${baseUrl}/123e4567-e89b-42d3-a456-426614174000`); + expect(capturedRequest).toMatchObject({ + method: "POST", + url: "/render/api/artifacts", + authorization: "Bearer test-token", + }); + const decoded = await decodeFragmentAsync(capturedRequest.payload ?? ""); + expect(decoded.ok).toBe(true); + if (decoded.ok) { + expect(decoded.envelope.artifacts[0]).toMatchObject({ + kind: "json", + content: "{\"ready\":true}\n", + }); + } + }); +}); diff --git a/cli/tests/kind.test.ts b/cli/tests/kind.test.ts new file mode 100644 index 0000000..c0665b2 --- /dev/null +++ b/cli/tests/kind.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { detectArtifactKind } from "../src/kind"; + +describe("detectArtifactKind", () => { + it.each([ + ["README.md", { kind: "markdown" }], + ["changes.patch", { kind: "diff" }], + ["rows.csv", { kind: "csv" }], + ["data.json", { kind: "json" }], + ["main.ts", { kind: "code", language: "typescript" }], + ["script.lua", { kind: "code", language: "lua" }], + ["LICENSE", { kind: "code", language: undefined }], + ] as const)("detects %s", (filename, expected) => { + expect(detectArtifactKind(filename)).toEqual(expected); + }); + + it("honors an explicit kind", () => { + expect(detectArtifactKind("notes.txt", "markdown")).toEqual({ kind: "markdown" }); + expect(detectArtifactKind("component.tsx", "code")).toEqual({ kind: "code", language: "tsx" }); + }); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..d689019 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "resolveJsonModule": true, + "noEmit": true, + "incremental": false + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..bb486a4 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("../src", import.meta.url)), + "brotli-wasm": fileURLToPath(new URL("../node_modules/brotli-wasm/index.node.js", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/package.json b/package.json index 24d1161..c7c3379 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "agent-render", "version": "0.1.0", "private": true, + "workspaces": [ + "cli" + ], "description": "A static, zero-retention artifact viewer for markdown, code, diffs, CSV, and JSON.", "license": "MIT", "homepage": "https://agent-render.com", From c55be5861af11cc334cd64b7d66a9b41ec3cbac7 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:59:59 -0400 Subject: [PATCH 04/32] bench: measure fragment codecs in tokens, not chars The codec ladder optimizes visible char length for chat-message limits, but when a fragment URL sits in an LLM's context the cost is tokens, and BMP-dense strings tokenize far worse than base64. scripts/bench-tokens.mjs reports visible chars, percent-escaped transport chars, and o200k_base tokens per codec and wire encoding across the bench corpus. Finding (TOKEN_BENCH_REPORT.md): baseBMP costs 31% more tokens than base64url on average across matched arx3/arx4 pairs (worst case 38%); arx4/base64url is token-optimal for nearly every sample kind. Follow-up candidate: a token-optimizing selection mode in the encoder and CLI. Co-Authored-By: Claude Fable 5 --- TOKEN_BENCH_REPORT.md | 97 ++++++++ package-lock.json | 8 + package.json | 1 + scripts/bench-tokens.mjs | 495 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 601 insertions(+) create mode 100644 TOKEN_BENCH_REPORT.md create mode 100644 scripts/bench-tokens.mjs diff --git a/TOKEN_BENCH_REPORT.md b/TOKEN_BENCH_REPORT.md new file mode 100644 index 0000000..a28b7d9 --- /dev/null +++ b/TOKEN_BENCH_REPORT.md @@ -0,0 +1,97 @@ +# Fragment codec token benchmark + +This benchmark reuses the corpus from `scripts/bench-codecs.mjs`. It measures the compact fragment body (codec tag plus payload), uses agent-render's conservative percent-escaped transport metric, and tokenizes with `gpt-tokenizer`'s `o200k_base` encoding. + +ARX and ARX2 report the wire selected by the current transport-length policy. ARX3 and ARX4 report matched base64url and baseBMP variants produced from the same compressed/coded bytes. The o200k_base counts are directional for Claude tokenizers. + +| sample | kind | codec | wire | visible fragment chars | percent-encoded transport chars | o200k_base tokens | +|---|---|---|---|---:|---:|---:| +| markdown-agents | markdown | plain | base64url | 11116 | 11116 | 7229 | +| markdown-agents | markdown | lz | uri-safe | 4068 | 4154 | 2709 | +| markdown-agents | markdown | deflate | base64url | 733 | 733 | 503 | +| markdown-agents | markdown | arx | base64url | 547 | 547 | 368 | +| markdown-agents | markdown | arx2 | base64url | 538 | 538 | 371 | +| markdown-agents | markdown | arx3 | base64url | 538 | 538 | 371 | +| markdown-agents | markdown | arx3 | baseBMP | 206 | 1825 | 482 | +| markdown-agents | markdown | arx4 | base64url | 440 | 440 | 300 | +| markdown-agents | markdown | arx4 | baseBMP | 170 | 1475 | 380 | +| code-bench-report | markdown | plain | base64url | 11468 | 11468 | 7421 | +| code-bench-report | markdown | lz | uri-safe | 5669 | 5767 | 3835 | +| code-bench-report | markdown | deflate | base64url | 3703 | 3703 | 2484 | +| code-bench-report | markdown | arx | base64url | 2987 | 2987 | 2002 | +| code-bench-report | markdown | arx2 | base64url | 2962 | 2962 | 1993 | +| code-bench-report | markdown | arx3 | base64url | 2962 | 2962 | 1993 | +| code-bench-report | markdown | arx3 | baseBMP | 1120 | 9976 | 2593 | +| code-bench-report | markdown | arx4 | base64url | 2572 | 2572 | 1722 | +| code-bench-report | markdown | arx4 | baseBMP | 973 | 8660 | 2281 | +| code-fragment | code | plain | base64url | 11281 | 11281 | 7345 | +| code-fragment | code | lz | uri-safe | 3471 | 3561 | 2364 | +| code-fragment | code | deflate | base64url | 679 | 679 | 478 | +| code-fragment | code | arx | base64url | 530 | 530 | 355 | +| code-fragment | code | arx2 | base64url | 521 | 521 | 358 | +| code-fragment | code | arx3 | base64url | 521 | 521 | 358 | +| code-fragment | code | arx3 | baseBMP | 199 | 1759 | 456 | +| code-fragment | code | arx4 | base64url | 463 | 463 | 304 | +| code-fragment | code | arx4 | baseBMP | 178 | 1565 | 421 | +| diff-patch | diff | plain | base64url | 2264 | 2264 | 1526 | +| diff-patch | diff | lz | uri-safe | 762 | 774 | 518 | +| diff-patch | diff | deflate | base64url | 239 | 239 | 165 | +| diff-patch | diff | arx | base64url | 175 | 175 | 117 | +| diff-patch | diff | arx2 | base64url | 154 | 154 | 108 | +| diff-patch | diff | arx3 | base64url | 154 | 154 | 108 | +| diff-patch | diff | arx3 | baseBMP | 61 | 529 | 146 | +| diff-patch | diff | arx4 | base64url | 110 | 110 | 76 | +| diff-patch | diff | arx4 | baseBMP | 45 | 383 | 102 | +| diff-pair | diff | plain | base64url | 5516 | 5516 | 3638 | +| diff-pair | diff | lz | uri-safe | 799 | 819 | 553 | +| diff-pair | diff | deflate | base64url | 208 | 208 | 136 | +| diff-pair | diff | arx | base64url | 138 | 138 | 86 | +| diff-pair | diff | arx2 | base64url | 126 | 126 | 85 | +| diff-pair | diff | arx3 | base64url | 126 | 126 | 85 | +| diff-pair | diff | arx3 | baseBMP | 51 | 439 | 111 | +| diff-pair | diff | arx4 | base64url | 80 | 80 | 55 | +| diff-pair | diff | arx4 | baseBMP | 34 | 281 | 67 | +| csv-grid | csv | plain | base64url | 9331 | 9331 | 5964 | +| csv-grid | csv | lz | uri-safe | 2246 | 2304 | 1514 | +| csv-grid | csv | deflate | base64url | 1699 | 1699 | 1134 | +| csv-grid | csv | arx | base64url | 842 | 842 | 564 | +| csv-grid | csv | arx2 | base64url | 834 | 834 | 582 | +| csv-grid | csv | arx3 | base64url | 834 | 834 | 582 | +| csv-grid | csv | arx3 | baseBMP | 317 | 2809 | 734 | +| csv-grid | csv | arx4 | base64url | 1015 | 1015 | 671 | +| csv-grid | csv | arx4 | baseBMP | 386 | 3428 | 901 | +| json-package | json | plain | base64url | 1199 | 1199 | 748 | +| json-package | json | lz | uri-safe | 695 | 703 | 453 | +| json-package | json | deflate | base64url | 516 | 516 | 349 | +| json-package | json | arx | base64url | 463 | 463 | 321 | +| json-package | json | arx2 | base64url | 431 | 431 | 286 | +| json-package | json | arx3 | base64url | 431 | 431 | 286 | +| json-package | json | arx3 | baseBMP | 166 | 1462 | 385 | +| json-package | json | arx4 | base64url | 404 | 404 | 266 | +| json-package | json | arx4 | baseBMP | 156 | 1370 | 355 | +| multi-bundle | bundle | plain | base64url | 34103 | 34103 | 22208 | +| multi-bundle | bundle | lz | uri-safe | 12018 | 12264 | 8132 | +| multi-bundle | bundle | deflate | base64url | 2411 | 2411 | 1619 | +| multi-bundle | bundle | arx | base64url | 1759 | 1759 | 1188 | +| multi-bundle | bundle | arx2 | base64url | 1674 | 1674 | 1158 | +| multi-bundle | bundle | arx3 | base64url | 1674 | 1674 | 1158 | +| multi-bundle | bundle | arx3 | baseBMP | 634 | 5641 | 1508 | +| multi-bundle | bundle | arx4 | base64url | 1635 | 1635 | 1089 | +| multi-bundle | bundle | arx4 | baseBMP | 620 | 5507 | 1448 | + +## Conclusions + +BaseBMP loses to base64url on o200k_base tokens on average. Across 16 matched ARX3/ARX4 sample pairs, baseBMP uses 31.15% more tokens on average (184.1 more tokens per fragment). + +The worst case is code-fragment with arx4: baseBMP uses 38.49% more tokens (117 tokens) than base64url. + +Token-optimal codec/wire combination per sample kind (summing samples when a kind has more than one fixture): + +- markdown: arx4/base64url (2022 tokens) +- code: arx4/base64url (304 tokens) +- diff: arx4/base64url (131 tokens) +- csv: arx/base64url (564 tokens) +- json: arx4/base64url (266 tokens) +- bundle: arx4/base64url (1089 tokens) + +These o200k_base counts are directional, not exact, for Claude tokenizers. diff --git a/package-lock.json b/package-lock.json index d2d522b..ba4cbdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "esbuild": "^0.27.3", "eslint": "^9.22.0", "eslint-config-next": "15.5.18", + "gpt-tokenizer": "^3.4.0", "jsdom": "^28.1.0", "tailwindcss": "^4.0.6", "tsx": "^4.22.4", @@ -6700,6 +6701,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", diff --git a/package.json b/package.json index c7c3379..e3c0e0d 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "esbuild": "^0.27.3", "eslint": "^9.22.0", "eslint-config-next": "15.5.18", + "gpt-tokenizer": "^3.4.0", "jsdom": "^28.1.0", "tailwindcss": "^4.0.6", "tsx": "^4.22.4", diff --git a/scripts/bench-tokens.mjs b/scripts/bench-tokens.mjs new file mode 100644 index 0000000..f67ec1e --- /dev/null +++ b/scripts/bench-tokens.mjs @@ -0,0 +1,495 @@ +#!/usr/bin/env node + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { encode } from "gpt-tokenizer/encoding/o200k_base"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const REPORT_PATH = fileURLToPath(new URL("../TOKEN_BENCH_REPORT.md", import.meta.url)); + +process.chdir(ROOT); + +const bridgeDirectory = mkdtempSync(join(ROOT, ".token-bench-")); +const bridgePath = join(bridgeDirectory, "codec-bridge.mjs"); +let codecModule; + +try { + await build({ + alias: { + "brotli-wasm": join(ROOT, "node_modules/brotli-wasm/index.node.js"), + }, + stdin: { + contents: [ + 'export { encodeEnvelope, getFragmentTransportLength, getVisibleFragmentLength } from "./src/lib/payload/fragment.ts";', + 'export { buildArxCandidates, buildArx2Candidates, buildArx3Candidates, buildArx4Candidates } from "./src/lib/payload/fragment-arx.ts";', + 'export { isBase1kEncoded, isBase64urlEncoded, isBaseBMPEncoded, loadArxDictionarySync, loadArx2OverlayDictionarySync } from "./src/lib/payload/arx-codec.ts";', + 'export { loadArx4PriorsSync } from "./src/lib/payload/arx4-codec.ts";', + ].join("\n"), + resolveDir: ROOT, + sourcefile: "token-bench-codec-bridge.ts", + }, + bundle: true, + external: [join(ROOT, "node_modules/brotli-wasm/index.node.js")], + format: "esm", + outfile: bridgePath, + platform: "node", + tsconfig: join(ROOT, "tsconfig.json"), + }); + codecModule = await import(pathToFileURL(bridgePath).href); +} finally { + rmSync(bridgeDirectory, { recursive: true, force: true }); +} + +const { + encodeEnvelope, + getFragmentTransportLength, + getVisibleFragmentLength, + buildArxCandidates, + buildArx2Candidates, + buildArx3Candidates, + buildArx4Candidates, + isBase1kEncoded, + isBase64urlEncoded, + isBaseBMPEncoded, + loadArxDictionarySync, + loadArx2OverlayDictionarySync, + loadArx4PriorsSync, +} = codecModule; + +loadArxDictionarySync(JSON.parse(readFileSync("public/arx-dictionary.json", "utf8"))); +loadArx2OverlayDictionarySync(JSON.parse(readFileSync("public/arx2-dictionary.json", "utf8"))); +loadArx4PriorsSync(JSON.parse(readFileSync("public/arx4-priors.json", "utf8"))); + +const codeBenchReportFixture = readFileSync("tests/fixtures/baanish-code-bench-report.md", "utf8"); + +function textEnvelope(kind, title, content, extra = {}) { + return { + v: 1, + codec: "plain", + title, + activeArtifactId: "a", + artifacts: [ + { + id: "a", + kind, + title, + filename: extra.filename ?? "artifact.txt", + content, + ...extra, + }, + ], + }; +} + +function repeatedFixture(block, targetLength, segmentSuffix = (index) => `\nfixture segment ${index}\n`) { + let fixture = ""; + let index = 0; + while (fixture.length < targetLength) { + fixture += `${block}${segmentSuffix(index)}`; + index++; + } + return Array.from(fixture).slice(0, targetLength).join(""); +} + +const markdownAgentsFixture = repeatedFixture( + [ + "# AGENTS.md excerpt", + "", + "`agent-render` is a static artifact viewer for AI-generated outputs.", + "Keep markdown, code, diffs, CSV, and JSON readable across chat surfaces.", + "", + "## Product contract", + "", + "- Fragment payloads use `#agent-render=v1..`.", + "- Artifact contents stay out of the host request path.", + "- Supported codecs are `plain`, `lz`, `deflate`, `arx`, `arx2`, and `arx3`.", + "- Supported artifact kinds are `markdown`, `code`, `diff`, `csv`, and `json`.", + "", + "Preserve the static shell, the zero-retention wording, and the renderer-first layout.", + "", + ].join("\n"), + 8000, + (index) => `\nFixture note ${index}: fragment transport, renderer readiness, and artifact metadata stay aligned.\n\n`, +); + +const codeFragmentFixture = repeatedFixture( + [ + "export async function decodeFragmentAsync(hash: string, options?: DecodeOptions) {", + " const parsed = parseFragmentPrefix(hash);", + " if (!parsed.ok) return parsed;", + " if (parsed.codec === \"arx\" || parsed.codec === \"arx2\") {", + " const { decodeArxFragmentAsync } = await import(\"./fragment-arx\");", + " return decodeArxFragmentAsync(parsed, options);", + " }", + " return decodePlainFragment(parsed.payload, options);", + "}", + "", + "export async function encodeEnvelopeAsync(envelope: PayloadEnvelope, options: EncodeOptions = {}) {", + " const codec = options.codec ?? envelope.codec ?? \"deflate\";", + " if (codec === \"arx\" || codec === \"arx2\") {", + " const { encodeArxEnvelopeAsync } = await import(\"./fragment-arx\");", + " return encodeArxEnvelopeAsync(envelope, codec);", + " }", + " return encodeEnvelope(envelope, { codec });", + "}", + "", + ].join("\n"), + 8000, + (index) => `\n// fixture segment ${index}: codec branch coverage and bundle shape stay stable.\n`, +); + +const packageManifestFixture = JSON.stringify( + { + name: "agent-render", + version: "0.1.0", + private: true, + scripts: { + build: "next build", + preview: "node scripts/serve-export.mjs", + check: "npm run lint && npm run test && npm run bench:codecs && npm run typecheck && npm run build", + }, + dependencies: { + "@codemirror/view": "^6.38.2", + "@git-diff-view/react": "^0.1.1", + "brotli-wasm": "^3.0.1", + "fflate": "^0.8.2", + "lucide-react": "^0.577.0", + "next": "15.1.11", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-markdown": "^10.1.0", + }, + devDependencies: { + "@playwright/test": "^1.58.2", + typescript: "^5.8.2", + vitest: "^4.0.18", + }, + }, + null, + 2, +); + +const readmeFixture = repeatedFixture( + [ + "# agent-render", + "", + "A static, open artifact viewer for AI outputs.", + "", + "Paste content into the browser-side link creator, choose a renderer, and share the resulting fragment URL.", + "The static host serves the shell; the browser decodes the artifact from the fragment.", + "", + "## Supported artifacts", + "", + "- Markdown with sanitized GFM and Mermaid fences.", + "- Code with a read-only CodeMirror surface.", + "- Review-style git patches with unified and split modes.", + "- CSV tables and JSON trees.", + "", + ].join("\n"), + 9000, + (index) => `\nFixture section ${index}: static export links should remain inspectable across chat clients.\n\n`, +); + +const arxCodecFixture = repeatedFixture( + [ + "const singleByteCodes = [0x01, 0x02, 0x03, 0x04, 0x05];", + "function buildPairs(dictionary, prefix = \"\\\\x00\") {", + " return dictionary.extendedSlots.map((slot, index) => [slot, prefix + String.fromCharCode(index + 1)]);", + "}", + "function applyTrie(text, trie) {", + " const out = [];", + " let index = 0;", + " while (index < text.length) {", + " let node = trie;", + " let cursor = index;", + " let replacement;", + " while (cursor < text.length) {", + " node = node.children.get(text[cursor]);", + " if (!node) break;", + " cursor++;", + " if (node.replacement !== undefined) replacement = node.replacement;", + " }", + " out.push(replacement ?? text[index]);", + " index++;", + " }", + " return out.join(\"\");", + "}", + "", + ].join("\n"), + 12000, + (index) => `\n// fixture segment ${index}: trie substitutions and tuple overlays remain comparable.\n`, +); + +const tsconfigFixture = JSON.stringify( + { + compilerOptions: { + target: "ES2022", + lib: ["dom", "dom.iterable", "esnext"], + allowJs: false, + skipLibCheck: true, + strict: true, + noEmit: true, + module: "esnext", + moduleResolution: "bundler", + resolveJsonModule: true, + isolatedModules: true, + jsx: "preserve", + paths: { + "@/*": ["./src/*"], + }, + }, + include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + exclude: ["node_modules"], + }, + null, + 2, +); + +const patch = [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-export const value = 1;", + "+export const value = 2;", + "", +].join("\n").repeat(12); + +const csvRows = ["name,value,notes"]; +for (let index = 0; index < 180; index++) { + csvRows.push(`row-${index},${index},"export const value ${index}"`); +} +const csv = csvRows.join("\n"); + +const corpus = [ + { + name: "markdown-agents", + kind: "markdown", + envelope: textEnvelope("markdown", "AGENTS.md excerpt", markdownAgentsFixture, { + filename: "AGENTS.md", + }), + }, + { + name: "code-bench-report", + kind: "markdown", + envelope: textEnvelope("markdown", "Baanish Code Bench", codeBenchReportFixture, { + filename: "results.md", + }), + }, + { + name: "code-fragment", + kind: "code", + envelope: textEnvelope("code", "fragment.ts excerpt", codeFragmentFixture, { + filename: "fragment.ts", + language: "ts", + }), + }, + { + name: "diff-patch", + kind: "diff", + envelope: { + v: 1, + codec: "plain", + title: "Patch review", + activeArtifactId: "patch", + artifacts: [{ id: "patch", kind: "diff", filename: "change.patch", patch, view: "split" }], + }, + }, + { + name: "diff-pair", + kind: "diff", + envelope: { + v: 1, + codec: "plain", + title: "Old/new diff", + activeArtifactId: "pair", + artifacts: [{ + id: "pair", + kind: "diff", + filename: "pair.ts", + oldContent: "export const value = 1;\n".repeat(80), + newContent: "export const value = 2;\n".repeat(80), + language: "ts", + view: "unified", + }], + }, + }, + { + name: "csv-grid", + kind: "csv", + envelope: textEnvelope("csv", "CSV grid", csv, { filename: "grid.csv" }), + }, + { + name: "json-package", + kind: "json", + envelope: textEnvelope("json", "package.json", packageManifestFixture, { filename: "package.json" }), + }, + { + name: "multi-bundle", + kind: "bundle", + envelope: { + v: 1, + codec: "plain", + title: "Mixed bundle", + activeArtifactId: "source", + artifacts: [ + { id: "readme", kind: "markdown", filename: "README.md", content: readmeFixture }, + { + id: "source", + kind: "code", + filename: "arx-codec.ts", + language: "ts", + content: arxCodecFixture, + }, + { id: "patch", kind: "diff", filename: "bundle.patch", patch, view: "split" }, + { id: "table", kind: "csv", filename: "table.csv", content: csv.slice(0, 1200) }, + { id: "manifest", kind: "json", filename: "tsconfig.json", content: tsconfigFixture }, + ], + }, + }, +]; + +function identifyArxWire(fragment, codec) { + const payload = fragment.slice(codec === "arx4" ? 2 : 1); + if (isBaseBMPEncoded(payload)) return "baseBMP"; + if (isBase64urlEncoded(payload)) return "base64url"; + if (isBase1kEncoded(payload)) return "base1k"; + return "base76"; +} + +function shortestCandidate(candidates) { + return candidates.reduce((shortest, candidate) => ( + candidate.transportLength < shortest.transportLength ? candidate : shortest + )); +} + +function rowFor(entry, codec, wire, fragment) { + return { + sample: entry.name, + kind: entry.kind, + codec, + wire, + visibleChars: getVisibleFragmentLength(fragment), + transportChars: getFragmentTransportLength(fragment), + tokens: encode(fragment).length, + }; +} + +function candidateForWire(candidates, codec, wire) { + const candidate = candidates.find((item) => identifyArxWire(item.value, codec) === wire); + if (!candidate) { + throw new Error(`Missing ${codec} ${wire} candidate.`); + } + return candidate; +} + +const rows = []; + +for (const entry of corpus) { + for (const codec of ["plain", "lz", "deflate"]) { + const fragment = encodeEnvelope(entry.envelope, { codec }); + rows.push(rowFor(entry, codec, codec === "lz" ? "uri-safe" : "base64url", fragment)); + } + + const arxCandidates = [ + ...await buildArxCandidates(entry.envelope, true, getFragmentTransportLength), + ...await buildArxCandidates(entry.envelope, false, getFragmentTransportLength), + ]; + const selectedArx = shortestCandidate(arxCandidates); + rows.push(rowFor(entry, "arx", identifyArxWire(selectedArx.value, "arx"), selectedArx.value)); + + const arx2Candidates = await buildArx2Candidates(entry.envelope, getFragmentTransportLength); + const selectedArx2 = shortestCandidate(arx2Candidates); + rows.push(rowFor(entry, "arx2", identifyArxWire(selectedArx2.value, "arx2"), selectedArx2.value)); + + const arx3Candidates = await buildArx3Candidates(entry.envelope, getFragmentTransportLength); + for (const wire of ["base64url", "baseBMP"]) { + const candidate = candidateForWire(arx3Candidates, "arx3", wire); + rows.push(rowFor(entry, "arx3", wire, candidate.value)); + } + + const arx4Candidates = await buildArx4Candidates(entry.envelope, getFragmentTransportLength); + for (const wire of ["base64url", "baseBMP"]) { + const candidate = candidateForWire(arx4Candidates, "arx4", wire); + rows.push(rowFor(entry, "arx4", wire, candidate.value)); + } +} + +const table = [ + "| sample | kind | codec | wire | visible fragment chars | percent-encoded transport chars | o200k_base tokens |", + "|---|---|---|---|---:|---:|---:|", + ...rows.map((row) => ( + `| ${row.sample} | ${row.kind} | ${row.codec} | ${row.wire} | ${row.visibleChars} | ${row.transportChars} | ${row.tokens} |` + )), +]; + +const bmpComparisons = []; +for (const entry of corpus) { + for (const codec of ["arx3", "arx4"]) { + const base64url = rows.find((row) => ( + row.sample === entry.name && row.codec === codec && row.wire === "base64url" + )); + const baseBMP = rows.find((row) => ( + row.sample === entry.name && row.codec === codec && row.wire === "baseBMP" + )); + const tokenDelta = baseBMP.tokens - base64url.tokens; + bmpComparisons.push({ + sample: entry.name, + codec, + tokenDelta, + percentDelta: tokenDelta / base64url.tokens, + }); + } +} + +const averageTokenDelta = bmpComparisons.reduce((sum, item) => sum + item.tokenDelta, 0) / bmpComparisons.length; +const averagePercentDelta = bmpComparisons.reduce((sum, item) => sum + item.percentDelta, 0) / bmpComparisons.length; +const worstComparison = bmpComparisons.reduce((worst, item) => ( + item.percentDelta > worst.percentDelta ? item : worst +)); +const baseBmpLoses = averageTokenDelta > 0; + +const kindWinners = []; +for (const kind of [...new Set(corpus.map((entry) => entry.kind))]) { + const totals = new Map(); + for (const row of rows.filter((item) => item.kind === kind)) { + const combination = `${row.codec}/${row.wire}`; + totals.set(combination, (totals.get(combination) ?? 0) + row.tokens); + } + const [combination, tokens] = [...totals.entries()].reduce((best, item) => ( + item[1] < best[1] ? item : best + )); + kindWinners.push({ kind, combination, tokens }); +} + +const conclusions = [ + "## Conclusions", + "", + `BaseBMP ${baseBmpLoses ? "loses" : "does not lose"} to base64url on o200k_base tokens on average. Across ${bmpComparisons.length} matched ARX3/ARX4 sample pairs, baseBMP uses ${Math.abs(averagePercentDelta * 100).toFixed(2)}% ${averagePercentDelta >= 0 ? "more" : "fewer"} tokens on average (${Math.abs(averageTokenDelta).toFixed(1)} ${averageTokenDelta >= 0 ? "more" : "fewer"} tokens per fragment).`, + "", + `The worst case is ${worstComparison.sample} with ${worstComparison.codec}: baseBMP uses ${(worstComparison.percentDelta * 100).toFixed(2)}% more tokens (${worstComparison.tokenDelta} tokens) than base64url.`, + "", + "Token-optimal codec/wire combination per sample kind (summing samples when a kind has more than one fixture):", + "", + ...kindWinners.map((winner) => `- ${winner.kind}: ${winner.combination} (${winner.tokens} tokens)`), + "", + "These o200k_base counts are directional, not exact, for Claude tokenizers.", +]; + +const report = [ + "# Fragment codec token benchmark", + "", + "This benchmark reuses the corpus from `scripts/bench-codecs.mjs`. It measures the compact fragment body (codec tag plus payload), uses agent-render's conservative percent-escaped transport metric, and tokenizes with `gpt-tokenizer`'s `o200k_base` encoding.", + "", + "ARX and ARX2 report the wire selected by the current transport-length policy. ARX3 and ARX4 report matched base64url and baseBMP variants produced from the same compressed/coded bytes. The o200k_base counts are directional for Claude tokenizers.", + "", + ...table, + "", + ...conclusions, + "", +].join("\n"); + +writeFileSync(REPORT_PATH, report); +console.log(report); From 621386f8d79f5270d08645e2e7a549fe0a4f4914 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:02:32 -0400 Subject: [PATCH 05/32] integration: cli html/choices kinds, workspace lockfile, bench report into docs The cli lane was built before the viewer kinds landed, so it capped --kind at the original five. Explicit --kind html shares kit markup (.html files still auto-detect as code source view so arbitrary HTML is never silently reinterpreted) and --kind choices reads a JSON document shaped {prompt?, multi?, options}. The token bench report moves under docs/ and the lockfile picks up the cli workspace. Co-Authored-By: Claude Fable 5 --- cli/src/cli.ts | 5 +- cli/src/envelope.ts | 56 ++++++++++++++++- cli/tests/envelope.test.ts | 61 +++++++++++++++++++ .../token-bench-report.md | 0 package-lock.json | 24 ++++++++ scripts/bench-tokens.mjs | 2 +- 6 files changed, 145 insertions(+), 3 deletions(-) rename TOKEN_BENCH_REPORT.md => docs/token-bench-report.md (100%) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index fe170b5..ec080a2 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -22,7 +22,10 @@ type CreateOptions = { token?: string; }; -const KINDS = new Set(["auto", "markdown", "code", "diff", "csv", "json"]); +// `.html` files auto-detect as code (source view); kit rendering is an explicit `--kind html` so +// arbitrary HTML files are never silently reinterpreted. `--kind choices` reads a JSON document +// shaped {"prompt"?, "multi"?, "options": [{"id", "label", "detail"?}]}. +const KINDS = new Set(["auto", "markdown", "code", "diff", "csv", "json", "html", "choices"]); const MODES = new Set(["auto", "instance", "fragment"]); const FORMATS = new Set(["url", "markdown", "discord", "slack", "plain"]); const DEFAULT_VIEWER_URL = "https://agent-render.com/"; diff --git a/cli/src/envelope.ts b/cli/src/envelope.ts index 7229e93..9b7032c 100644 --- a/cli/src/envelope.ts +++ b/cli/src/envelope.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { ArtifactPayload, PayloadEnvelope } from "../../src/lib/payload/schema"; +import type { ArtifactPayload, ChoiceOption, PayloadEnvelope } from "../../src/lib/payload/schema"; import { normalizeEnvelope } from "../../src/lib/payload/envelope"; import { detectArtifactKind, type RequestedKind } from "./kind"; @@ -15,6 +15,48 @@ function slugify(value: string): string { .replace(/^-+|-+$/g, "") || "artifact"; } +type ChoicesDocument = { + prompt?: string; + multi?: boolean; + options: ChoiceOption[]; +}; + +const CHOICES_SHAPE = '{"prompt"?, "multi"?, "options": [{"id", "label", "detail"?}]}'; + +function parseChoicesDocument(input: ArtifactInput): ChoicesDocument { + let parsed: unknown; + try { + parsed = JSON.parse(input.content); + } catch { + throw new Error(`Choices input ${input.filename} must be JSON shaped ${CHOICES_SHAPE}.`); + } + + if (typeof parsed !== "object" || parsed === null || !Array.isArray((parsed as { options?: unknown }).options)) { + throw new Error(`Choices input ${input.filename} must be JSON shaped ${CHOICES_SHAPE}.`); + } + + const document = parsed as { prompt?: unknown; multi?: unknown; options: unknown[] }; + if (document.prompt !== undefined && typeof document.prompt !== "string") { + throw new Error(`Choices "prompt" in ${input.filename} must be a string.`); + } + if (document.multi !== undefined && typeof document.multi !== "boolean") { + throw new Error(`Choices "multi" in ${input.filename} must be a boolean.`); + } + + const options = document.options.map((option, index) => { + const record = option as { id?: unknown; label?: unknown; detail?: unknown }; + if (typeof record?.id !== "string" || typeof record.label !== "string") { + throw new Error(`Choices option ${index + 1} in ${input.filename} needs string "id" and "label".`); + } + if (record.detail !== undefined && typeof record.detail !== "string") { + throw new Error(`Choices option "${record.id}" in ${input.filename} has a non-string "detail".`); + } + return { id: record.id, label: record.label, detail: record.detail }; + }); + + return { prompt: document.prompt, multi: document.multi, options }; +} + function buildArtifact( input: ArtifactInput, requestedKind: RequestedKind, @@ -27,6 +69,18 @@ function buildArtifact( if (detected.kind === "diff") { return { id, kind: "diff", title, filename, patch: input.content, view: "unified" }; } + if (detected.kind === "choices") { + const document = parseChoicesDocument(input); + return { + id, + kind: "choices", + title, + filename, + prompt: document.prompt, + multi: document.multi, + options: document.options, + }; + } if (detected.kind === "code") { return { id, diff --git a/cli/tests/envelope.test.ts b/cli/tests/envelope.test.ts index 33a0670..69f5ab6 100644 --- a/cli/tests/envelope.test.ts +++ b/cli/tests/envelope.test.ts @@ -27,4 +27,65 @@ describe("buildPayloadEnvelope", () => { filename: "report.md", }); }); + + it("builds a kit html artifact when --kind html is explicit", () => { + const envelope = buildPayloadEnvelope( + [{ filename: "report.html", content: '
ok
' }], + "html", + ); + + expect(envelope.artifacts[0]).toMatchObject({ + kind: "html", + content: '
ok
', + }); + }); + + it("keeps .html files as code source view under auto detection", () => { + const envelope = buildPayloadEnvelope([{ filename: "page.html", content: "

hi

" }], "auto"); + expect(envelope.artifacts[0]).toMatchObject({ kind: "code", language: "html" }); + }); + + it("parses a choices JSON document", () => { + const envelope = buildPayloadEnvelope( + [ + { + filename: "next-steps.json", + content: JSON.stringify({ + prompt: "Which fixes land?", + multi: true, + options: [ + { id: "a", label: "Fix TTL", detail: "off by one" }, + { id: "b", label: "Document auth" }, + ], + }), + }, + ], + "choices", + ); + + expect(envelope.artifacts[0]).toMatchObject({ + kind: "choices", + prompt: "Which fixes land?", + multi: true, + options: [ + { id: "a", label: "Fix TTL", detail: "off by one" }, + { id: "b", label: "Document auth" }, + ], + }); + }); + + it("rejects malformed choices documents with a shape hint", () => { + expect(() => buildPayloadEnvelope([{ filename: "bad.json", content: "not json" }], "choices")).toThrow( + /must be JSON shaped/, + ); + expect(() => + buildPayloadEnvelope([{ filename: "bad.json", content: '{"options": [{"id": 1, "label": "x"}]}' }], "choices"), + ).toThrow(/string "id" and "label"/); + expect(() => + buildPayloadEnvelope( + [{ filename: "dup.json", content: '{"options": [{"id": "a", "label": "x"}, {"id": "a", "label": "y"}]}' }], + "choices", + ), + ).toThrow(/duplicate option id/); + }); }); diff --git a/TOKEN_BENCH_REPORT.md b/docs/token-bench-report.md similarity index 100% rename from TOKEN_BENCH_REPORT.md rename to docs/token-bench-report.md diff --git a/package-lock.json b/package-lock.json index ba4cbdf..f9f0904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "agent-render", "version": "0.1.0", "license": "MIT", + "workspaces": [ + "cli" + ], "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", @@ -62,6 +65,23 @@ "better-sqlite3": "^12.8.0" } }, + "cli": { + "name": "@agent-render/cli", + "version": "0.1.0", + "license": "MIT", + "bin": { + "agent-render": "dist/index.cjs" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.8.2", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -76,6 +96,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@agent-render/cli": { + "resolved": "cli", + "link": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/scripts/bench-tokens.mjs b/scripts/bench-tokens.mjs index f67ec1e..944e605 100644 --- a/scripts/bench-tokens.mjs +++ b/scripts/bench-tokens.mjs @@ -7,7 +7,7 @@ import { build } from "esbuild"; import { encode } from "gpt-tokenizer/encoding/o200k_base"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); -const REPORT_PATH = fileURLToPath(new URL("../TOKEN_BENCH_REPORT.md", import.meta.url)); +const REPORT_PATH = fileURLToPath(new URL("../docs/token-bench-report.md", import.meta.url)); process.chdir(ROOT); From 6700f05dd942cbbe638713433302c621b06541ef Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:05:30 -0400 Subject: [PATCH 06/32] build: root typecheck excludes cli workspace and runs its own tsc The cli workspace has its own tsconfig, but the root tsc globbed cli/** and compiled its tests under Next's augmented ProcessEnv, failing on env literals the cli's own stricter-scoped tsconfig accepts. Root tsc now excludes cli and the root typecheck delegates to the cli workspace tsc, so both are covered without cross-contaminating lib/types. config.ts env params use a narrow EnvLookup record instead of NodeJS.ProcessEnv. Co-Authored-By: Claude Fable 5 --- cli/src/config.ts | 13 ++++++++----- cli/tsconfig.json | 3 ++- package.json | 2 +- tsconfig.json | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/src/config.ts b/cli/src/config.ts index 481abd5..86673c8 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -13,6 +13,9 @@ export type ResolvedConfig = StoredConfig & { configPath: string; }; +/** The env shape these helpers read: a bag of optional string vars, not the framework-augmented ProcessEnv. */ +export type EnvLookup = Readonly>; + function normalizeConfigKey(key: string): ConfigKey { const normalized = key.replace(/[-_]/g, "").toLowerCase(); if (normalized === "instanceurl") return "INSTANCE_URL"; @@ -21,14 +24,14 @@ function normalizeConfigKey(key: string): ConfigKey { } /** Resolves the XDG-compatible agent-render config file path. */ -export function getConfigPath(env: NodeJS.ProcessEnv = process.env): string { +export function getConfigPath(env: EnvLookup = process.env): string { const configHome = env.XDG_CONFIG_HOME?.trim(); const home = env.HOME?.trim() || os.homedir(); return path.join(configHome || path.join(home, ".config"), "agent-render", "config.json"); } /** Reads stored CLI configuration, treating a missing file as empty configuration. */ -export async function readStoredConfig(env: NodeJS.ProcessEnv = process.env): Promise { +export async function readStoredConfig(env: EnvLookup = process.env): Promise { const configPath = getConfigPath(env); let contents: string; try { @@ -53,7 +56,7 @@ export async function readStoredConfig(env: NodeJS.ProcessEnv = process.env): Pr /** Resolves CLI configuration with flags taking precedence over environment and file values. */ export async function resolveConfig( flags: StoredConfig = {}, - env: NodeJS.ProcessEnv = process.env, + env: EnvLookup = process.env, ): Promise { const stored = await readStoredConfig(env); return { @@ -67,7 +70,7 @@ export async function resolveConfig( export async function setConfigValue( keyInput: string, value: string, - env: NodeJS.ProcessEnv = process.env, + env: EnvLookup = process.env, ): Promise { const key = normalizeConfigKey(keyInput); const configPath = getConfigPath(env); @@ -83,7 +86,7 @@ export async function setConfigValue( /** Reads one supported value directly from the config file. */ export async function getConfigValue( keyInput: string, - env: NodeJS.ProcessEnv = process.env, + env: EnvLookup = process.env, ): Promise { const key = normalizeConfigKey(keyInput); const config = await readStoredConfig(env); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index d689019..3e1829f 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -10,5 +10,6 @@ "noEmit": true, "incremental": false }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["../node_modules"] } diff --git a/package.json b/package.json index e3c0e0d..706b791 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "bench:codecs": "node scripts/bench-codecs.mjs", "bench:codecs:update": "node scripts/bench-codecs.mjs --write-baseline", "assets:compress": "node scripts/compress-dictionary.mjs", - "typecheck": "node scripts/ensure-next-types.mjs && tsc --noEmit", + "typecheck": "node scripts/ensure-next-types.mjs && tsc --noEmit && npm run typecheck --workspace cli", "check": "npm run lint && npm run test && npm run bench:codecs && npm run typecheck && npm run build && npm run check:build-budgets", "check:build-budgets": "node scripts/check-build-budgets.mjs", "check:public-export-docs": "node scripts/check-public-export-docs.mjs", diff --git a/tsconfig.json b/tsconfig.json index 3911166..33c7242 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ ".next/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "cli" ] } From 7b48ca06123b4a52108a5096f3340ff9670f5cde Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:17:35 -0400 Subject: [PATCH 07/32] selfhosted: derive auth key via scrypt, gate Secure cookie on TLS Two review findings on the password gate: - CodeQL flagged the shared password being fed straight into HMAC. It now passes through scrypt once at startup; per-request cookie checks use the derived key and bearer checks re-derive via scrypt, so online guessing on the write endpoint is rate-limited and a leaked cookie can't be brute-forced back to the password. - Greptile (P1): /auth issued an unconditional Secure cookie, which browsers drop over plain HTTP, looping every gated page back to the sign-in form on direct-HTTP (Tailscale/LAN) deployments. Secure is now set only when the request arrived over TLS (X-Forwarded-Proto or a direct TLS socket). Co-Authored-By: Claude Fable 5 --- selfhosted/server.ts | 42 +++++++++++++++++++++++++++-------- tests/selfhosted/auth.test.ts | 17 +++++++++++++- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/selfhosted/server.ts b/selfhosted/server.ts index 4adea94..80e22c8 100644 --- a/selfhosted/server.ts +++ b/selfhosted/server.ts @@ -1,7 +1,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, readFileSync, createReadStream, statSync } from "node:fs"; import { stat } from "node:fs/promises"; -import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import type { TLSSocket } from "node:tls"; import path from "node:path"; import { createArtifact, @@ -25,6 +26,14 @@ const API_CATALOG_LINK_HEADER = '; rel="api-catalog"; const authPassword = process.env.AGENT_RENDER_PASSWORD; const authCookieName = "agent_render_auth"; const authSalt = randomBytes(32); +// The shared password only ever flows through scrypt (a slow KDF), never a bare hash, so online +// guessing on the write endpoint is rate-limited by the derivation cost and a leaked cookie cannot +// be brute-forced back to the password. The salt is ephemeral (process memory only), so cookies +// are invalidated on restart. authKey is null when no password is configured (gate disabled). +const authKey = authPassword === undefined ? null : scryptSync(authPassword, authSalt, 32); +// The cookie the browser holds derives from authKey, not the raw password, and is a fixed-length +// public token checked per request with a cheap constant-time compare. +const authCookieToken = authKey === null ? "" : createHmac("sha256", authSalt).update(authKey).digest("base64url"); const contentTypes = new Map([ [".html", "text/html; charset=utf-8"], @@ -329,8 +338,20 @@ function constantTimeEqual(left: string, right: string): boolean { return timingSafeEqual(leftDigest, rightDigest); } -function expectedAuthCookie(): string { - return createHmac("sha256", authSalt).update(authPassword ?? "").digest("base64url"); +/** Verifies a candidate password against the derived key in constant time via the same slow KDF. */ +function isValidPassword(candidate: string): boolean { + if (authKey === null) return true; + return timingSafeEqual(scryptSync(candidate, authSalt, 32), authKey); +} + +/** True when the request reached the server over TLS, directly or via a terminating proxy. */ +function isSecureRequest(req: IncomingMessage): boolean { + const forwardedProto = String(req.headers["x-forwarded-proto"] ?? "") + .split(",")[0] + .trim() + .toLowerCase(); + if (forwardedProto) return forwardedProto === "https"; + return (req.socket as TLSSocket).encrypted === true; } function cookieValue(req: IncomingMessage, name: string): string | null { @@ -345,16 +366,16 @@ function cookieValue(req: IncomingMessage, name: string): string | null { } function hasValidCookie(req: IncomingMessage): boolean { - if (authPassword === undefined) return true; + if (authKey === null) return true; const supplied = cookieValue(req, authCookieName); - return supplied !== null && constantTimeEqual(supplied, expectedAuthCookie()); + return supplied !== null && constantTimeEqual(supplied, authCookieToken); } function hasValidApiAuth(req: IncomingMessage): boolean { - if (authPassword === undefined || hasValidCookie(req)) return true; + if (authKey === null || hasValidCookie(req)) return true; const authorization = req.headers.authorization; if (!authorization?.startsWith("Bearer ")) return false; - return constantTimeEqual(authorization.slice("Bearer ".length), authPassword); + return isValidPassword(authorization.slice("Bearer ".length)); } function safeRedirect(value: string | null): string { @@ -505,10 +526,13 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise } const redirect = safeRedirect(form.get("redirect")); const suppliedPassword = form.get("password") ?? ""; - if (authPassword === undefined || constantTimeEqual(suppliedPassword, authPassword)) { + if (isValidPassword(suppliedPassword)) { + // Secure only when the request arrived over TLS: on a direct-HTTP deployment the browser would + // drop a Secure cookie and every gated page would loop back to the sign-in form. + const secureAttribute = isSecureRequest(req) ? " Secure;" : ""; res.setHeader( "Set-Cookie", - `${authCookieName}=${expectedAuthCookie()}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=31536000`, + `${authCookieName}=${authCookieToken}; HttpOnly;${secureAttribute} SameSite=Lax; Path=/; Max-Age=31536000`, ); res.writeHead(303, { Location: redirect }); res.end(); diff --git a/tests/selfhosted/auth.test.ts b/tests/selfhosted/auth.test.ts index c881ff7..8739139 100644 --- a/tests/selfhosted/auth.test.ts +++ b/tests/selfhosted/auth.test.ts @@ -226,16 +226,31 @@ describe("optional self-hosted password gate", () => { expect(setCookie).toContain("agent_render_auth="); expect(setCookie).not.toContain(password); expect(setCookie).toContain("HttpOnly"); - expect(setCookie).toContain("Secure"); expect(setCookie).toContain("SameSite=Lax"); expect(setCookie).toContain("Path=/"); expect(setCookie).toContain("Max-Age=31536000"); + // Over plain HTTP the cookie must not be Secure, or the browser drops it and login loops. + expect(setCookie).not.toContain("Secure"); const page = await fetch(`${base}/security`, { headers: { Cookie: cookie } }); expect(page.status).toBe(200); expect(await page.text()).toContain("Security"); }); + it("marks the cookie Secure when the request arrives over forwarded TLS", async () => { + const login = await fetch(`${base}/auth`, { + method: "POST", + redirect: "manual", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Forwarded-Proto": "https", + }, + body: new URLSearchParams({ password, redirect: "/" }), + }); + expect(login.status).toBe(303); + expect(login.headers.get("set-cookie") ?? "").toContain("Secure"); + }); + it("rejects a wrong form password and will not redirect off-origin", async () => { const wrong = await fetch(`${base}/auth`, { method: "POST", From 289c54c7825aeeabefa2e7b1aef039c43ef768e8 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:36:54 -0400 Subject: [PATCH 08/32] harden kit html and self-hosted auth after review Security review (grok, CodeRabbit, ChatGPT-Codex) on the new surfaces: - Trusted (server-injected) HTML rendered in an unsandboxed same-origin srcDoc iframe: full origin takeover. Now sandboxed with allow-scripts but not allow-same-origin, so scripts and forms still run while the document sits in an opaque origin, unable to reach the parent DOM, the auth cookie, or the artifact API. - Untrusted HTML went through dangerouslySetInnerHTML, so the sanitizer output was serialized and re-parsed (mutation-XSS surface). It now adopts the sanitized DOM nodes directly. Unknown tags are dropped wholesale (default-deny) instead of unwrapped, and hrefs are limited to https/mailto (no http downgrade, no bare-fragment shell-hash takeover). - The scrypt password check ran synchronously on the event loop, so an unauthenticated Bearer flood could stall the process. It is async now and rejects oversized candidates before the KDF. - X-Forwarded-Proto was trusted unconditionally, letting a client force the Secure cookie flag off; it is honored only when AGENT_RENDER_TRUST_PROXY=1. - Empty AGENT_RENDER_PASSWORD now disables the gate instead of enabling it with a blank secret. Co-Authored-By: Claude Fable 5 --- docs/deployment.md | 3 ++ docs/design-kit.md | 11 +++-- docs/payload-format.md | 4 +- selfhosted/server.ts | 49 +++++++++++++------ skills/agent-render-linking/SKILL.md | 2 +- src/components/renderers/html-renderer.tsx | 32 ++++++------ src/lib/html/sanitize-kit-html.ts | 48 +++++++++++++----- tests/sanitize-kit-html.test.ts | 29 +++++++++-- tests/selfhosted/auth.test.ts | 57 +++++++++++++++++++++- 9 files changed, 177 insertions(+), 58 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 149c617..d57daec 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -82,6 +82,7 @@ The server starts on port 3000. Create artifacts via `POST /api/artifacts` and v | `OUT_DIR` | `out` | Path to the static build output | | `AGENT_RENDER_TTL_HOURS` | `168` | Sliding artifact TTL in hours (positive integer) | | `AGENT_RENDER_PASSWORD` | unset | Shared-secret fallback auth; prefer a reverse proxy | +| `AGENT_RENDER_TRUST_PROXY` | unset | Set to `1` only behind a trusted TLS-terminating proxy to honor `X-Forwarded-Proto` | | `SHUTDOWN_GRACE_MS` | `5000` | Drain window before a forced (non-zero) exit on SIGTERM/SIGINT | ### Docker Compose @@ -152,6 +153,8 @@ For a small or local deployment without a separate auth layer, set `AGENT_RENDER The built-in password gates writes, browser pages, and artifact API reads. It is still a shared static secret, not per-user auth or an audit trail; use a reverse proxy or identity-aware proxy when you need real accounts. If `AGENT_RENDER_PASSWORD` is unset, the fallback is disabled and the server remains public; bind `HOST=127.0.0.1` if it should only be reachable locally. +The password is run through scrypt (never a bare hash), and the auth cookie is `Secure` only when the request arrived over TLS. The server sees the real socket scheme by default; behind a proxy that terminates TLS and forwards over HTTP, set `AGENT_RENDER_TRUST_PROXY=1` so it honors `X-Forwarded-Proto: https` and still marks the cookie `Secure`. Do not set it when the server is directly reachable, or a client could forge the header. + Every response carries baseline hardening headers: `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `X-Frame-Options: SAMEORIGIN`. HTML responses additionally carry a strict **`Content-Security-Policy`**. Its `script-src` allows only same-origin scripts, the build's own inline scripts (by `sha256` hash, derived at runtime from the served `index.html` so they never drift from the build), and — on a stored-artifact viewer page — the injected payload bootstrap (by a per-response `nonce`). So even if a renderer dependency regressed into an injection sink, attacker-controlled inline script in a stored payload cannot execute. It also includes `'wasm-unsafe-eval'`, which the arx-family codecs need to decompress Brotli via WebAssembly — this permits WebAssembly compilation but not JavaScript `eval`, so it is far narrower than `'unsafe-eval'`. The policy also sets `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'self'`, and `form-action 'self'`. `img-src` and `connect-src` are restricted to same-origin (plus `data:`/`blob:`). This is deliberate: because the server **stores** the payload, an artifact cannot beacon out or load a tracking pixel from a cross-origin URL. The tradeoff is that a legitimately cross-origin image referenced inside an artifact will not render on the self-hosted viewer (it loads fine on the fragment-based static product, which ships no such policy) — widen `img-src` at a reverse proxy if cross-origin images are a use case you need. diff --git a/docs/design-kit.md b/docs/design-kit.md index 94530ea..dd9933f 100644 --- a/docs/design-kit.md +++ b/docs/design-kit.md @@ -12,8 +12,11 @@ inline styles and `