diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b787971..dfb0ffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,9 @@ jobs: - name: Build run: npm run build + + - name: CLI workspace checks + run: | + npm run typecheck --workspace @agent-render/cli + npm run test --workspace @agent-render/cli + npm run build --workspace @agent-render/cli diff --git a/.gitignore b/.gitignore index b1efc58..b524229 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,11 @@ final-light*.png # Self-hosted server runtime database (local default ./data/agent-render.db; see selfhosted/db.ts) /data/*.db* + +# Token bench esbuild bridge. Lives in the repo (not os.tmpdir()) so the bundle's external +# brotli-wasm specifier still resolves through node_modules; cleaned up on exit, ignored in case +# the run is killed. See scripts/bench-tokens.mjs. +/.token-bench-*/ + +# Local planning docs; not part of the published repo +/docs/design/ diff --git a/AGENTS.md b/AGENTS.md index 1c40b0f..775d2a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ If the code and this file diverge, trust the code first and update this file. `agent-render` is a fully static, zero-retention artifact viewer for AI-generated outputs. -It is meant to make markdown, code, diffs, CSV, and JSON readable across chat surfaces that do a poor job rendering rich artifacts inline. +It is meant to make markdown, code, diffs, CSV, JSON, and kit HTML readable across chat surfaces that do a poor job rendering rich artifacts inline. Core product traits right now: - open source @@ -25,7 +25,7 @@ Treat these as core constraints unless the owner explicitly changes the product - The app is a single exported client-side shell, not a backend product. - Artifact payloads live in the URL fragment, using the compact `#` form where the single tag char identifies the codec: `p` plain, `l` lz, `d` deflate, `a` arx, `b` arx2, `c` arx3, `e` arx4. Legacy `#agent-render=v1..` links (arx-family carry an extra `.` segment) still decode but are no longer emitted. - The deployed host should not receive artifact contents as part of the initial page request. -- Supported artifact kinds are `markdown`, `code`, `diff`, `csv`, and `json`. +- Supported artifact kinds are `markdown`, `code`, `diff`, `csv`, `json`, and `html`. - Supported codecs are `plain`, `lz`, `deflate`, `arx`, `arx2`, `arx3`, and `arx4`. - The product is zero-retention by host design, not secret-safe in an absolute sense. - Links may still leak through browser history, copied URLs, screenshots, and any future client-side analytics. @@ -60,6 +60,7 @@ Describe and preserve what is already true in the repo today. - `diff` uses a review-style git patch viewer with unified and split modes. - `csv` renders as a readable table/grid. - `json` renders as a lightweight structured tree plus raw fallback behavior. +- `html` renders agent markup against the design kit shipped in the viewer (`docs/design-kit.md`). Fragment payloads are sanitized to the kit vocabulary and adopted inline; server-injected self-hosted payloads render verbatim in the origin-isolated `/artifact-frame.html`, which carries its own CSP. ### Performance and bundling diff --git a/README.md b/README.md index 36bdc21..f847f26 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, and kit HTML dashboards so the payload stays in the browser URL fragment instead of being sent to a server. ## OpenClaw @@ -28,7 +28,7 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari ## Status -- Markdown, code, diff, CSV, and JSON all render in the static shell +- Markdown, code, diff, CSV, JSON, and kit HTML all render in the static shell - Fragment transport supports `plain`, `lz`, `deflate`, `arx`, `arx2`, `arx3`, and `arx4`, with automatic shortest-fragment selection across available wire formats - The `arx` substitution dictionary is served at `/arx-dictionary.json` with a pre-compressed `/arx-dictionary.json.br` variant; the `arx2` tuple-envelope overlay is served at `/arx2-dictionary.json` with a pre-compressed `/arx2-dictionary.json.br` variant; `arx3` reuses those proven bytes and optimizes for compact visible Unicode fragments; `arx4` adds the curated context-mixer priors at `/arx4-priors.json` with a pre-compressed `/arx4-priors.json.br` variant - The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, and (for markdown) supports browser print-to-PDF @@ -41,6 +41,7 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari - `diff` - review-style multi-file git patch viewer with unified and split modes - `csv` - parsed table view with sticky headers and horizontal overflow handling - `json` - lightweight read-only tree view plus native raw source view, with graceful malformed JSON fallback +- `html` - rich layout built from the shipped design kit; sanitized for fragment links, sandboxed iframe for server-injected payloads ## Principles @@ -69,7 +70,7 @@ In addition to the default static/fragment-based product, `agent-render` include **What it provides:** - REST API for creating, reading, updating, and deleting artifacts - UUID-based viewer links that render the same UI as fragment links -- 24-hour sliding TTL with automatic expiry +- 7-day sliding TTL (configurable) with automatic expiry - SQLite storage — no external database required - Docker Compose and daemon deployment options 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..9a12433 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,191 @@ +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 { assertEnvelopeWithinBudget, 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; +}; + +// `.html` files auto-detect as code (source view); kit rendering is an explicit `--kind html` so +// arbitrary HTML files are never silently reinterpreted. +const KINDS = new Set(["auto", "markdown", "code", "diff", "csv", "json", "html"]); +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; +} + +/** + * Decodes input as strict UTF-8. + * + * Buffer.toString("utf8") silently replaces malformed bytes with U+FFFD, so a mis-encoded file (a + * latin-1 diff, a truncated multibyte sequence) would be published with corrupted characters and no + * warning. Failing is the only honest option: the CLI cannot know the intended encoding. + */ +function decodeUtf8Strict(bytes: Buffer, source: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`${source} is not valid UTF-8. Convert it to UTF-8 before sharing it.`); + } +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of defaultStdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return decodeUtf8Strict(Buffer.concat(chunks), "Input on stdin"); +} + +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: decodeUtf8Strict(await readFile(filename), filename), + }))); +} + +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); + assertEnvelopeWithinBudget(envelope); + // Only read stored config when something is actually missing from it. Explicit fragment mode needs + // no instance settings at all, and an explicit --instance-url with --token is fully specified, so + // neither should be failed by a malformed config file they never consult. + const needsStoredConfig = + options.mode !== "fragment" && !(options.instanceUrl !== undefined && options.token !== undefined); + const config = needsStoredConfig + ? await resolveConfig({ instanceUrl: options.instanceUrl, token: options.token }) + : { instanceUrl: options.mode === "fragment" ? undefined : 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); + // The markdown surface is a different candidate (selected by percent-escaped length) with its + // own visible length, so it needs its own budget check — but only when the chosen format + // actually emits it, or an oversized markdown candidate would fail a --format url run whose own + // link is well within budget. + const usesMarkdownSurface = options.format === "markdown" || options.format === "discord"; + if (encoded.transportFragmentBody === encoded.fragmentBody) { + markdownUrl = url; + } else { + if (usesMarkdownSurface) assertFragmentBudget(encoded.transportFragmentBody); + 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..7bbabb3 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,142 @@ +import { chmod, 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; +}; + +/** 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"; + 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: 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: EnvLookup = 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; + } + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + // Name the file: config is read on every create, including fragment mode that needs none, so a + // bare SyntaxError here would fail an unrelated command with no way to find the cause. + throw new Error(`Config file ${configPath} is not valid JSON.`); + } + 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. + * + * The endpoint and its credential resolve together, not independently. Resolving them separately + * means `--instance-url https://other-host` (with no `--token`) sends a token stored for a + * *different* instance to that host. + */ +export async function resolveConfig( + flags: StoredConfig = {}, + env: EnvLookup = process.env, +): Promise { + const stored = await readStoredConfig(env); + + // Index of the config-file layer below; the stored token is only usable when the endpoint came + // from that same file. + const STORED_LAYER_INDEX = 2; + const layers: { instanceUrl?: string; token?: string }[] = [ + { instanceUrl: flags.instanceUrl, token: flags.token }, + { instanceUrl: env.AGENT_RENDER_INSTANCE_URL, token: env.AGENT_RENDER_TOKEN }, + { instanceUrl: stored.instanceUrl, token: stored.token }, + ]; + + const urlLayerIndex = layers.findIndex((layer) => layer.instanceUrl !== undefined); + const instanceUrl = urlLayerIndex === -1 ? undefined : layers[urlLayerIndex]!.instanceUrl; + + // A credential is only sent to the endpoint it belongs to. Two ways a layer can claim ownership: + // + // - it names its own instanceUrl, in which case its token is for THAT host and is usable only + // when that host is the one selected (an env pair overridden by --instance-url must not have + // its token follow the override), and + // - it is the config file, whose token is a stored credential for the stored instance even when + // the file records no URL of its own; otherwise `--instance-url https://attacker` would hand a + // token-only config straight over. + // + // A layer that supplies a bare token and no URL of its own (an explicit --token, or + // AGENT_RENDER_TOKEN beside a config-file URL) is not claiming an endpoint, so it stays usable. + const envTokenIsBound = env.AGENT_RENDER_INSTANCE_URL === undefined + || env.AGENT_RENDER_INSTANCE_URL === instanceUrl; + const storedTokenIsBound = urlLayerIndex === STORED_LAYER_INDEX; + const token = + flags.token + ?? (envTokenIsBound ? env.AGENT_RENDER_TOKEN : undefined) + ?? (storedTokenIsBound ? stored.token : undefined); + + return { + instanceUrl, + token, + configPath: getConfigPath(env), + }; +} + +/** Stores one supported CLI configuration value. */ +export async function setConfigValue( + keyInput: string, + value: string, + env: EnvLookup = 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 }); + // writeFile's mode only applies when it creates the file, so an existing world-readable config + // would keep its permissions while now holding a bearer token. + await chmod(configPath, 0o600); + return configPath; +} + +/** Reads one supported value directly from the config file. */ +export async function getConfigValue( + keyInput: string, + env: EnvLookup = 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..2c84b95 --- /dev/null +++ b/cli/src/encoding.ts @@ -0,0 +1,69 @@ +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_DECODED_PAYLOAD_LENGTH, 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}`; +} + +/** + * Rejects an envelope larger than the decoded-payload budget before any encode or upload, so both + * fragment and instance modes fail fast with a clear message instead of a wasted round trip or an + * undecodable link. + */ +export function assertEnvelopeWithinBudget(envelope: PayloadEnvelope): void { + const decodedLength = JSON.stringify(envelope).length; + if (decodedLength > MAX_DECODED_PAYLOAD_LENGTH) { + throw new Error( + `This artifact is ${decodedLength.toLocaleString()} characters, over the ${MAX_DECODED_PAYLOAD_LENGTH.toLocaleString()} character payload limit.`, + ); + } +} + +/** 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..aada719 --- /dev/null +++ b/cli/src/envelope.ts @@ -0,0 +1,78 @@ +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 { + // Reserve every real slug first, so a generated `-N` suffix can never collide with a later + // file whose own name slugifies to that same string (report-2.md alongside two report.md files). + const takenIds = new Set( + inputs.map((input) => slugify(path.basename(input.filename, path.extname(input.filename)))), + ); + const usedIds = new Set(); + const artifacts = inputs.map((input) => { + const baseId = slugify(path.basename(input.filename, path.extname(input.filename))); + let id = baseId; + let suffix = 2; + while (usedIds.has(id) || (id !== baseId && takenIds.has(id))) { + id = `${baseId}-${suffix}`; + suffix += 1; + } + usedIds.add(id); + return buildArtifact(input, requestedKind, id, 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..6a7cc26 --- /dev/null +++ b/cli/src/instance.ts @@ -0,0 +1,71 @@ +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(); +} + +/** The server's own id contract; anything else is not something we will paste into a URL. */ +const ARTIFACT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function parseArtifactCreated(value: unknown): ArtifactCreated { + const id = typeof value === "object" && value !== null ? (value as { id?: unknown }).id : undefined; + // Validated, not just typed: an id like "../login" would survive URL normalization as a path + // segment and the CLI would report a confident link to somewhere the artifact is not. + if (typeof id !== "string" || !ARTIFACT_ID_PATTERN.test(id)) { + throw new Error("The agent-render instance returned an invalid create response."); + } + return { 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}`); + } + + let parsedBody: unknown; + try { + parsedBody = JSON.parse(responseText); + } catch { + // A 2xx with a non-JSON body (proxy error page, misconfigured server) should give the same + // clear message as a malformed JSON body, not a raw SyntaxError. + throw new Error("The agent-render instance returned an invalid create response."); + } + + const created = parseArtifactCreated(parsedBody); + 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..b73f92d --- /dev/null +++ b/cli/tests/config.test.ts @@ -0,0 +1,115 @@ +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("never sends a stored token to an --instance-url override for another host", async () => { + const home = await temporaryHome(); + const fileEnv = { HOME: home }; + await setConfigValue("INSTANCE_URL", "https://private-a", fileEnv); + await setConfigValue("TOKEN", "secret-a", fileEnv); + + // The config file's token belongs to the config file's instance; pointing the CLI somewhere else + // must not carry that credential along. + const overridden = await resolveConfig({ instanceUrl: "https://other-b" }, { HOME: home }); + expect(overridden.instanceUrl).toBe("https://other-b"); + expect(overridden.token).toBeUndefined(); + + // An explicit --token is a deliberate choice and still applies. + const explicit = await resolveConfig( + { instanceUrl: "https://other-b", token: "for-b" }, + { HOME: home }, + ); + expect(explicit.token).toBe("for-b"); + }); + + it("does not send an environment-scoped token to an --instance-url override", async () => { + const home = await temporaryHome(); + // The env pair names its own host, so its token belongs to that host and must not follow an + // override elsewhere -- the same rule as the config file, applied to the env layer. + const environment = { + HOME: home, + AGENT_RENDER_INSTANCE_URL: "https://env-host", + AGENT_RENDER_TOKEN: "env-secret", + }; + + const overridden = await resolveConfig({ instanceUrl: "https://attacker.example" }, environment); + expect(overridden.instanceUrl).toBe("https://attacker.example"); + expect(overridden.token).toBeUndefined(); + + // Without an override the env pair is used as configured. + const unchanged = await resolveConfig({}, environment); + expect(unchanged.instanceUrl).toBe("https://env-host"); + expect(unchanged.token).toBe("env-secret"); + }); + + it("does not treat a token-only config file as a portable credential", async () => { + const home = await temporaryHome(); + // No instanceUrl stored: the token is still a stored credential for this file's instance, not a + // secret to hand to whatever host the caller names. + await setConfigValue("TOKEN", "stored-secret", { HOME: home }); + + const resolved = await resolveConfig({ instanceUrl: "https://attacker.example" }, { HOME: home }); + expect(resolved.instanceUrl).toBe("https://attacker.example"); + expect(resolved.token).toBeUndefined(); + }); + + it("still pairs a config-file URL with a token supplied only by the environment", async () => { + const home = await temporaryHome(); + await setConfigValue("INSTANCE_URL", "https://private-a", { HOME: home }); + + // The env token names no host of its own, so it is not tied to a different endpoint. + const resolved = await resolveConfig({}, { HOME: home, AGENT_RENDER_TOKEN: "ci-secret" }); + expect(resolved.instanceUrl).toBe("https://private-a"); + expect(resolved.token).toBe("ci-secret"); + }); + + 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..3ac0083 --- /dev/null +++ b/cli/tests/envelope.test.ts @@ -0,0 +1,63 @@ +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("does not let a generated suffix collide with a real filename slug", () => { + const envelope = buildPayloadEnvelope( + [ + { filename: "report-2.md", content: "# Two" }, + { filename: "a/report.md", content: "# A" }, + { filename: "b/report.md", content: "# B" }, + ], + "auto", + ); + + const ids = envelope.artifacts.map((artifact) => artifact.id); + expect(new Set(ids).size).toBe(3); + expect(ids).toEqual(["report-2", "report", "report-3"]); + }); + + 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", + }); + }); + + 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" }); + }); + +}); 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..0ea49e4 --- /dev/null +++ b/cli/tests/fragment.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { decodeFragmentAsync } from "../../src/lib/payload/fragment"; +import { buildPayloadEnvelope } from "../src/envelope"; +import { MAX_DECODED_PAYLOAD_LENGTH } from "../../src/lib/payload/schema"; +import { + assertEnvelopeWithinBudget, + 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", + }); + } + }); + + it("rejects an envelope over the decoded payload budget before encoding", () => { + const envelope = buildPayloadEnvelope( + [{ filename: "big.md", content: "x".repeat(MAX_DECODED_PAYLOAD_LENGTH + 1) }], + "auto", + ); + expect(() => assertEnvelopeWithinBudget(envelope)).toThrow(/payload limit/); + }); +}); 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..3e1829f --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "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"], + "exclude": ["../node_modules"] +} 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/docs/architecture.md b/docs/architecture.md index 1986f30..d6ca988 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,8 +26,9 @@ The static export also emits `sitemap.xml` at the site root (and under `NEXT_PUB - `diff` - review-style diff view with unified and split modes - `csv` - table-focused data grid built from parsed rows and dynamic columns - `json` - lightweight read-only tree view plus a native raw source view +- `html` - kit-styled rich layout; sanitized and adopted inline for fragments, sandboxed iframe for server-injected payloads -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 six artifact kinds (markdown, code, diff, csv, json, html) 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. @@ -151,7 +152,7 @@ SQLite with a single `artifacts` table: ### TTL -Artifacts use a 24-hour sliding TTL. Each successful read (API or viewer) extends `expires_at` by 24 hours. Expired entries are lazily deleted on read, swept automatically on startup and once an hour, and can also be batch-cleaned on demand via `POST /api/cleanup`. +Artifacts use a seven-day sliding TTL by default (`AGENT_RENDER_TTL_HOURS` overrides it). Each successful read (API or viewer) extends `expires_at` by that duration. Expired entries are lazily deleted on read, swept automatically on startup and once an hour, and can also be batch-cleaned on demand via `POST /api/cleanup`. UUID mode should not be described as zero-retention in its current form. The server stores the encoded payload until expiry or deletion. A future encrypted short-link mode could store ciphertext in SQLite while keeping the decryption key in the URL fragment, but that design is not implemented. diff --git a/docs/deployment.md b/docs/deployment.md index 89cc7fb..ac4371d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,13 +74,16 @@ 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 | +| `AGENT_RENDER_TRUST_PROXY` | unset | Set to `1` only behind a trusted proxy, to honor `X-Forwarded-Proto` and per-client `X-Forwarded-For` | +| `SHUTDOWN_GRACE_MS` | `5000` | Drain window before a forced (non-zero) exit on SIGTERM/SIGINT | ### Docker Compose @@ -135,16 +138,22 @@ 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`, `SameSite=Lax` cookie (`Secure` on TLS requests) 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. + +The password is run through scrypt (never a bare hash), and failed attempts are rate-limited per client (10 per minute) so a credential flood cannot keep the KDF busy and stall unrelated requests. Behind a reverse proxy set `AGENT_RENDER_TRUST_PROXY=1`, or every client shares the proxy's address as one rate-limit bucket and a single bad actor can lock everyone else out; the proxy must append `X-Forwarded-For` (the nearest hop is used, since a client can forge earlier entries). 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'`. diff --git a/docs/design-kit.md b/docs/design-kit.md new file mode 100644 index 0000000..296f06a --- /dev/null +++ b/docs/design-kit.md @@ -0,0 +1,107 @@ +# 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("drops http and bare-fragment hrefs (cleartext downgrade and shell hash takeover)", () => { + expect(sanitizeKitHtml('x')).toBe("x"); + expect(sanitizeKitHtml('x')).toBe("x"); + }); + + it("rejects userinfo and embedded control characters in hrefs", () => { + // Userinfo is the classic display spoof: the label reads apple.com, the host is evil.example. + expect(sanitizeKitHtml('x')).toBe("x"); + // Parsers strip whitespace and controls before resolving, so a tab could smuggle a scheme past + // a check that only inspected the raw string. + expect(sanitizeKitHtml('x')).toBe("x"); + expect(sanitizeKitHtml('x')).toBe("x"); + // A scheme with no host resolves nowhere useful and is rejected rather than passed through. + expect(sanitizeKitHtml('x')).toBe("x"); + }); + + it("keeps scheme-relative https forms the URL parser normalizes to a real host", () => { + // `https:example.com` parses to https://example.com/. It looks odd but resolves to an ordinary + // https link, and the policy allows https to any host, so stripping it would be theatre. + 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("drops unknown tags and their subtree (default-deny)", () => { + expect(sanitizeKitHtml("

inner

kept

")).toBe("

kept

"); + }); + + it("drops nesting past the depth cap instead of overflowing the stack", () => { + const depth = MAX_KIT_HTML_DEPTH + 500; + const deep = "
".repeat(depth) + "boom" + "
".repeat(depth); + const output = sanitizeKitHtml(deep); + // The call returns rather than throwing RangeError, and the tree is truncated at the cap. + expect(output.split("
").length - 1).toBe(MAX_KIT_HTML_DEPTH); + }); + + 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 the documented ar-choices markup intact", () => { + // The kit component is the contract agents author against (docs/design-kit.md): the id badge is + // generated from data-ar-id by CSS, so losing that attribute would silently drop the option ids + // the reader is meant to reply with. + const input = + '
  1. Fix the TTL off-by-oneSweeper deletes an hour early.
  2. Document the auth header
'; + expect(sanitizeKitHtml(input)).toBe(input); + }); + + 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

"); + }); +}); diff --git a/tests/selfhosted/auth.test.ts b/tests/selfhosted/auth.test.ts new file mode 100644 index 0000000..b4af0a1 --- /dev/null +++ b/tests/selfhosted/auth.test.ts @@ -0,0 +1,427 @@ +// @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, + extraEnv?: Record, +): 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"), + ...extraEnv, + }; + 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("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("ignores X-Forwarded-Proto unless the proxy is trusted", async () => { + // Default deployment does not trust the header, so a forged https scheme must not add Secure. + 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") ?? "").not.toContain("Secure"); + }); + + 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 }); + } + }); +}); + +describe("self-hosted server with an empty password", () => { + it("refuses to start when AGENT_RENDER_PASSWORD is set but empty", async () => { + // `AGENT_RENDER_PASSWORD=${SECRET}` with SECRET missing expands to empty. Starting wide open + // there would leave an operator who explicitly configured auth with none, so it must fail loudly + // rather than be read as "auth off" (which is what an unset variable means). + const files = fixture(); + const port = await freePort(); + const child = startServer(port, files, ""); + try { + const exitCode = await new Promise((resolve) => { + child.once("close", (code) => resolve(code)); + }); + expect(exitCode).not.toBe(0); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); + +describe("self-hosted auth rate limiting", () => { + it("refuses further attempts before the KDF once the budget is spent", async () => { + const files = fixture(); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const child = startServer(port, files, "correct horse battery staple"); + try { + await waitForHealth(base, child); + + let sawRateLimit = false; + for (let attempt = 0; attempt < 15; attempt += 1) { + const response = await fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer wrong-${attempt}` }, + body: JSON.stringify({ payload: "pnope" }), + }); + if (response.status === 429) { + expect(response.headers.get("retry-after")).toBeTruthy(); + sawRateLimit = true; + break; + } + expect(response.status).toBe(401); + } + expect(sawRateLimit).toBe(true); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); + +describe("self-hosted concurrent auth cost", () => { + it("refuses a concurrent bearer burst instead of queueing a scrypt job per request", async () => { + const files = fixture(); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const child = startServer(port, files, "correct horse battery staple"); + try { + await waitForHealth(base, child); + + // Fired together, so none has failed-and-been-recorded when the others reach the pre-check. + // Without an in-flight budget every one of these would start its own KDF derivation. + const responses = await Promise.all( + Array.from({ length: 12 }, (_, attempt) => + fetch(`${base}/api/artifacts`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer burst-${attempt}` }, + body: JSON.stringify({ payload: "pburst" }), + }), + ), + ); + + const statuses = responses.map((response) => response.status); + expect(statuses.every((status) => status === 401 || status === 429)).toBe(true); + expect(statuses.filter((status) => status === 429).length).toBeGreaterThan(0); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); + +describe("self-hosted server with an unusable password", () => { + it("fails fast when AGENT_RENDER_PASSWORD exceeds the candidate length bound", async () => { + const files = fixture(); + const port = await freePort(); + // A password longer than the per-request bound would start cleanly and then reject every + // correct login, so startup must fail instead. + const child = startServer(port, files, "p".repeat(257)); + try { + const exitCode = await new Promise((resolve) => { + child.once("close", (code) => resolve(code)); + }); + expect(exitCode).not.toBe(0); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); + +describe("self-hosted server with a trusted proxy", () => { + it("honors X-Forwarded-Proto=https for the Secure cookie flag", async () => { + const files = fixture(); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const child = startServer(port, files, "correct horse battery staple", { + AGENT_RENDER_TRUST_PROXY: "1", + }); + try { + await waitForHealth(base, child); + 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: "correct horse battery staple", redirect: "/" }), + }); + expect(login.status).toBe(303); + expect(login.headers.get("set-cookie") ?? "").toContain("Secure"); + } finally { + await stopServer(child); + rmSync(files.root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/selfhosted/csp.test.ts b/tests/selfhosted/csp.test.ts index 0a42f20..5c39dc1 100644 --- a/tests/selfhosted/csp.test.ts +++ b/tests/selfhosted/csp.test.ts @@ -56,6 +56,7 @@ function createFixture(): { root: string; outDir: string } { writeFileSync(path.join(outDir, "index.html"), INDEX_HTML); writeFileSync(path.join(outDir, "sub", "index.html"), SUB_HTML); writeFileSync(path.join(outDir, "app.js"), "export const x = 1;\n"); + writeFileSync(path.join(outDir, "artifact-frame.html"), "frame"); return { root, outDir }; } @@ -116,6 +117,28 @@ describe("selfhosted Content-Security-Policy", () => { return ((await res.json()) as { id: string }).id; } + it("gives the artifact isolation frame its own CSP: inline scripts yes, exfiltration no", async () => { + // The frame exists to run a trusted artifact's own scripts, which the viewer's hash-only + // script-src forbids, so it must NOT inherit the viewer policy. What keeps it safe is the + // absence of connect-src and form-action, plus the sandbox on the embedding iframe. + const response = await fetch(`${base}/artifact-frame.html`); + expect(response.status).toBe(200); + const policy = response.headers.get("content-security-policy") ?? ""; + + expect(policy).toContain("script-src 'unsafe-inline'"); + expect(policy).toContain("default-src 'none'"); + expect(policy).toContain("form-action 'none'"); + expect(policy).not.toContain("connect-src"); + // No remote image host either: default-src 'none' does not cover images, so a permissive + // img-src would let a rendered artifact beacon what it renders back out via or CSS url(). + expect(policy).toContain("img-src 'self' data: blob:"); + expect(policy).not.toContain("https:"); + // The viewer's own pages keep the strict policy. + const viewer = await fetch(`${base}/index.html`); + expect(viewer.headers.get("content-security-policy") ?? "").toContain("'self'"); + expect(viewer.headers.get("content-security-policy") ?? "").not.toContain("script-src 'unsafe-inline'"); + }); + it("locks down script-src with self-derived hashes + a nonce on the injected viewer", async () => { const id = await createArtifact(); const res = await fetch(`${base}/${id}`); 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); }); }); 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" ] }