From 7a5c0c5e567a95219593e431be6900f2746c5100 Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Fri, 15 May 2026 13:53:48 -0700 Subject: [PATCH 01/11] docs: add HTML format design spec Brainstorm output for the planned HTML page format alongside A2UI. View-only, no JS, sandbox+CSP+sanitizer defense-in-depth. Two MCP tools (show_ui, show_html). No subdomain isolation; no-JS is a structural constraint enforced by CI. --- .../specs/2026-05-15-html-format-design.md | 584 ++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-html-format-design.md diff --git a/docs/superpowers/specs/2026-05-15-html-format-design.md b/docs/superpowers/specs/2026-05-15-html-format-design.md new file mode 100644 index 0000000..1d17cf9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-html-format-design.md @@ -0,0 +1,584 @@ +# HTML Page Format — Design + +Status: draft, awaiting user review (2026-05-15). + +## Goal + +Add **HTML** as a second first-class page format alongside A2UI. The +agent gets two single-purpose tools: + +- `show_ui(spec)` — emit an A2UI spec when you need a **structured + answer** back from the user (form, picker, confirmation). _Existing + behavior, unchanged._ +- `show_html(html)` — emit an HTML page when you need to **show** the + user a visual artifact (report, dashboard, chart, infographic, + comparison, slide). View-only; nothing comes back. + +The rule is binary: **`show_html` to show, `show_ui` to ask.** If you +need to read the user's response, use A2UI. + +## Why now + +PRD § _Scope V0 — out_ already names "Multi-format spec" as the +anticipated future wire-shape change. This is that change. A2UI is great +at the "structured input" lane but inadequate when the agent wants to +hand the user a rendered visual — there is no A2UI primitive for "a +report with these styled tables and inline SVG bars." HTML closes that +gap without disturbing A2UI. + +## Non-goals + +This spec deliberately excludes the following. Each is rejected with a +reason so future maintainers don't quietly add them back. + +- **JavaScript execution.** No ``, + - `
`, + - ``, + - ``, + - ``, + - `').output; + expect(out).not.toContain('', () => { + const out = sanitize('').output; + expect(out).not.toContain('', () => { + const out = sanitize('').output; + expect(out).not.toContain('', () => { + const out = sanitize('').output; + expect(out).not.toContain('', () => { + const out = sanitize('').output; + expect(out).not.toContain('', () => { + const out = sanitize('').output; + expect(out).not.toContain(' { + const out = sanitize('').output; + expect(out).not.toContain('onclick'); + expect(out).not.toContain('alert(1)'); + }); + + it('strips onerror on ', () => { + const out = sanitize('').output; + expect(out).not.toContain('onerror'); + }); + + it('strips javascript: URLs in href', () => { + const out = sanitize('click').output; + expect(out).not.toMatch(/javascript:/i); + }); + + it('strips vbscript: URLs', () => { + const out = sanitize('click').output; + expect(out).not.toMatch(/vbscript:/i); + }); + + it('strips data:text/html (executable data URL)', () => { + const out = sanitize('click').output; + expect(out).not.toContain('data:text/html'); + }); + + it('strips formaction (form override attack)', () => { + const out = sanitize('').output; + expect(out).not.toContain('formaction'); + }); + + it('strips srcdoc on any element', () => { + const out = sanitize('').output; + expect(out).not.toContain('srcdoc'); + }); + + it('preserves inline ', () => { + const input = ''; + const out = sanitize(input).output; + expect(out).toContain(' { + const out = sanitize('').output; + expect(out).not.toMatch(/xlink:href/i); + }); + + it('reports counts of removed tags and attrs', () => { + const r = sanitize(''); + expect(r.removedTags + r.removedAttrs).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd apps/api && npx vitest run sanitize.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 4: Implement the sanitizer** + +Create `apps/api/sanitize.ts`: + +```ts +/** + * Server-side HTML sanitization for the html page format. + * + * Runs once on POST /new before storage. Returns the cleaned HTML plus + * dropped-tag and dropped-attr counts (logged as forensic signal). + * + * Strict denylist, not allowlist — we accept arbitrary HTML/CSS/SVG and + * remove the dangerous parts. Combined with the iframe sandbox + meta-CSP + * in the renderer this is layer one of three (sanitizer → CSP → sandbox). + */ +import DOMPurify from 'isomorphic-dompurify'; + +const FORBID_TAGS = [ + 'script', + 'iframe', + 'frame', + 'frameset', + 'embed', + 'object', + 'applet', + 'link', // no external stylesheets + 'base', // we inject our own in the renderer scaffold + 'meta', // no ; renderer injects its own meta-CSP +]; + +const FORBID_ATTR = [ + 'formaction', + 'srcdoc', + 'xlink:href', +]; + +// Allow https links, mailto, in-page anchors, and inline image data URIs only. +// Explicitly blocks javascript:, vbscript:, data:text/html, data:application/*. +const ALLOWED_URI_REGEXP = + /^(?:https:|mailto:|#|data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,)/i; + +export function sanitize(html: string): { + output: string; + removedTags: number; + removedAttrs: number; +} { + const removed: { tag: 0; attr: 0 } = { tag: 0, attr: 0 } as { tag: 0; attr: 0 }; + // We use removed.* as numbers below; cast to keep the literal-zero type aside. + const counters = removed as unknown as { tag: number; attr: number }; + + const hooked = DOMPurify.addHook; + // Reset hook registry on every call by addHook'ing inside a fresh sanitize call. + // (DOMPurify hooks are global per instance — we re-add to keep the count fresh.) + DOMPurify.removeAllHooks(); + hooked('uponSanitizeElement', (_node, data) => { + if (data.allowedTags[data.tagName] === false) counters.tag++; + }); + hooked('uponSanitizeAttribute', (_node, data) => { + if (!data.allowedAttributes[data.attrName]) counters.attr++; + }); + + const output = DOMPurify.sanitize(html, { + USE_PROFILES: { html: true, svg: true }, + FORBID_TAGS, + FORBID_ATTR, + ALLOWED_URI_REGEXP, + ALLOW_DATA_ATTR: false, + WHOLE_DOCUMENT: false, + RETURN_TRUSTED_TYPE: false, + }) as string; + + DOMPurify.removeAllHooks(); + + return { + output, + removedTags: counters.tag, + removedAttrs: counters.attr, + }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/api && npx vitest run sanitize.test.ts` +Expected: PASS — all 21 cases green. If DOMPurify behaves slightly differently from expectations (e.g. inlining unknown attrs as text), adjust the test assertions to match observed safe behavior; do NOT loosen the sanitizer config. + +- [ ] **Step 6: Defer commit (Phase 1)** + +--- + +## Task 4: Update `store.createPage` and wire sanitizer into `POST /new` + +**Files:** +- Modify: `apps/api/store.ts` +- Modify: `apps/api/app.ts` +- Modify: `apps/api/app.test.ts` + +`createPage` accepts `format`. `POST /new` runs the sanitizer for HTML payloads, stores the sanitized output, logs the removed counts, and falls through to the existing flow for A2UI. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/app.test.ts` inside the main `describe`: + +```ts +describe('POST /new with format=html', () => { + it('accepts an HTML payload and returns 201 with { id, url, expires_at }', async () => { + const res = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + format: 'html', + spec: '

Hello

World

', + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { id: string; url: string; expires_at: number }; + expect(body.id).toMatch(/^[a-f0-9]{32}$/); + expect(body.url).toContain(body.id); + expect(typeof body.expires_at).toBe('number'); + }); + + it('strips ', + }), + }); + const { id } = (await res.json()) as { id: string }; + + const get = await app.request(`/${id}`); + const page = (await get.json()) as { format: string; spec: unknown }; + expect(page.format).toBe('html'); + expect(typeof page.spec).toBe('string'); + expect(page.spec as string).not.toContain('safe'); + }); + + it('rejects HTML payloads > 1 MB', async () => { + const big = 'a'.repeat(1_000_001); + const res = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ format: 'html', spec: big }), + }); + // bodyLimit middleware fires before Zod when body is over the absolute cap. + expect([400, 413]).toContain(res.status); + }); + + it('accepts A2UI payloads with implicit default format (backwards compat)', async () => { + const res = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ spec: [{ createSurface: { surfaceId: 'm' } }] }), + }); + expect(res.status).toBe(201); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/api && npx vitest run app.test.ts -t "format=html"` +Expected: FAIL — store.createPage doesn't carry `format`; GET /:id doesn't return `format`; sanitizer not wired. + +- [ ] **Step 3: Update `store.ts`** + +Replace `createPage` in `apps/api/store.ts` (lines 22–39) with: + +```ts +export async function createPage( + spec: unknown, + format: 'a2ui' | 'html', + cfg: CreatePageConfig, +): Promise { + const now = Date.now(); + const page: Page = { + id: newId(), + spec, + format, + state: 'open', + result: null, + createdAt: now, + expiresAt: now + cfg.pageTtlMs, + }; + await db.insertPage(page); + metrics.pagesCreated.add(1, { format }); + return { + id: page.id, + url: `${cfg.publicUrl}/${page.id}`, + expires_at: page.expiresAt, + }; +} +``` + +Note: `metrics.pagesCreated.add` gets a `format` label. If `metrics.ts` doesn't carry a labeled counter signature here today, just keep the existing `metrics.pagesCreated.add(1)` call without labels — don't widen the metrics interface in this task. + +- [ ] **Step 4: Wire sanitizer + format into `POST /new` handler** + +Update `newPageHandler` in `apps/api/app.ts` (lines 200–218): + +```ts +import { sanitize } from './sanitize.ts'; + +// … + +const newPageHandler = async (c: Context) => { + const raw = await c.req.json().catch(() => null); + const result = newPageBodySchema.safeParse(raw); + if (!result.success) { + return c.json( + { + error: 'bad_request', + issues: result.error.issues, + message: 'Request body did not match the expected schema', + }, + 400, + ); + } + const { format, spec } = result.data; + + let storedSpec = spec; + if (format === 'html') { + const { output, removedTags, removedAttrs } = sanitize(spec as string); + getLog(c).info( + { format, removedTags, removedAttrs }, + 'sanitized html submission', + ); + storedSpec = output; + } + + const created = await store.createPage(storedSpec, format, { + publicUrl: PUBLIC_URL, + pageTtlMs: PAGE_TTL_MS, + }); + return c.json(created, 201); +}; +``` + +Also bump `MAX_BODY_BYTES` so HTML payloads at the cap are accepted by the body-limit middleware. Replace line 41: + +```ts +// 1 MB is the HTML payload cap (per spec); A2UI's effective 256 KB cap is +// enforced post-parse in the handler. +export const MAX_BODY_BYTES = 1_000_000; +``` + +Add a post-parse A2UI cap check inside `newPageHandler` after the Zod parse and before `storedSpec` assignment: + +```ts + if (format === 'a2ui') { + // Enforce the historical 256 KB cap on A2UI specs; HTML uses the full 1 MB. + const serialized = JSON.stringify(spec ?? null); + if (serialized.length > 256_000) { + return c.json( + { + error: 'payload_too_large', + format: 'a2ui', + max_bytes: 256_000, + message: 'A2UI spec exceeds the 256 KB limit; use format: "html" for larger payloads only when appropriate', + }, + 413, + ); + } + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/api && npx vitest run app.test.ts -t "format=html"` +Expected: PASS — three HTML cases plus the backcompat A2UI case all green. + +- [ ] **Step 6: Defer commit (Phase 1)** + +--- + +## Task 5: Surface `format` in `GET /:id` and `GET /:id/result`; reject `POST /:id/result` on HTML pages + +**Files:** +- Modify: `apps/api/app.ts` +- Modify: `apps/api/app.test.ts` + +`GET /:id` now returns `format`; `GET /:id/result` returns `format` so the agent (or the renderer / tools) can detect an HTML page and stop polling. `POST /:id/result` on an HTML page returns `400 invalid_for_format`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/app.test.ts`: + +```ts +describe('format echo and HTML result handling', () => { + it('GET /:id includes format=html for an HTML page', async () => { + const created = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ format: 'html', spec: '

x

' }), + }); + const { id } = (await created.json()) as { id: string }; + + const res = await app.request(`/${id}`); + const body = (await res.json()) as { format: string }; + expect(body.format).toBe('html'); + }); + + it('GET /:id includes format=a2ui for an A2UI page', async () => { + const created = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ spec: [{ createSurface: { surfaceId: 'm' } }] }), + }); + const { id } = (await created.json()) as { id: string }; + + const res = await app.request(`/${id}`); + const body = (await res.json()) as { format: string }; + expect(body.format).toBe('a2ui'); + }); + + it('GET /:id/result includes format', async () => { + const created = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ format: 'html', spec: '

x

' }), + }); + const { id } = (await created.json()) as { id: string }; + + const res = await app.request(`/${id}/result`); + const body = (await res.json()) as { state: string; format: string; result: unknown }; + expect(body.format).toBe('html'); + expect(body.state).toBe('open'); + expect(body.result).toBe(null); + }); + + it('POST /:id/result rejects HTML pages with 400 invalid_for_format', async () => { + const created = await app.request('/new', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ format: 'html', spec: '

x

' }), + }); + const { id } = (await created.json()) as { id: string }; + + const res = await app.request(`/${id}/result`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'submitted', surfaceId: 'main' }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('invalid_for_format'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/api && npx vitest run app.test.ts -t "format echo"` +Expected: FAIL — handlers don't surface `format`; submit handler doesn't reject HTML. + +- [ ] **Step 3: Update `getPageHandler`** + +In `apps/api/app.ts`, replace the existing `getPageHandler` body (lines 220–232): + +```ts +const getPageHandler = async (c: Context) => { + const idResult = pageIdSchema.safeParse(c.req.param('id')); + if (!idResult.success) + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + const p = await db.getActivePage(idResult.data); + if (!p) return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + return c.json({ + spec: p.spec, + format: p.format, + state: p.state, + result: p.result, + expires_at: p.expiresAt, + }); +}; +``` + +- [ ] **Step 4: Update `getResultHandler`** + +In `apps/api/app.ts`, update `getResultHandler` (around line 267). Since `store.advanceResult` returns `CheckResultOutcome` without `format`, we need to extend that — or fetch format separately. Cleanest: extend `advanceResult` to return `format`. Update `apps/api/store.ts`: + +```ts +export async function advanceResult(id: string): Promise { + const r = await db.fetchAndAdvanceResult(id); + if (!r) return { kind: 'not_found' }; + return { kind: 'state', state: r.stateAtRead, result: r.result, format: r.format }; +} +``` + +Update `CheckResultOutcome` type in `apps/api/mcp/tools.ts`: + +```ts +export type CheckResultOutcome = + | { kind: 'not_found' } + | { kind: 'state'; state: PageState; result: unknown; format: 'a2ui' | 'html' }; +``` + +Then update `getResultHandler` in `apps/api/app.ts`: + +```ts +const getResultHandler = async (c: Context) => { + const idResult = pageIdSchema.safeParse(c.req.param('id')); + if (!idResult.success) + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + const outcome = await store.advanceResult(idResult.data); + if (outcome.kind === 'not_found') + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + return c.json({ state: outcome.state, result: outcome.result, format: outcome.format }); +}; +``` + +- [ ] **Step 5: Update `submitResultHandler` to reject HTML pages** + +In `apps/api/app.ts`, update `submitResultHandler` (around line 234). Insert a format check after the page-id parse but before reading the body: + +```ts +const submitResultHandler = async (c: Context) => { + const idResult = pageIdSchema.safeParse(c.req.param('id')); + if (!idResult.success) + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + + // Format check happens before body parse to fail fast on HTML pages. + const page = await db.getActivePage(idResult.data); + if (!page) + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + if (page.format !== 'a2ui') { + return c.json( + { + error: 'invalid_for_format', + format: page.format, + message: `POST /:id/result is not supported for format=${page.format}; HTML pages are view-only`, + }, + 400, + ); + } + + const raw = await c.req.json().catch(() => null); + const bodyResult = resultBodySchema.safeParse(raw); + if (!bodyResult.success) { + return c.json( + { + error: 'bad_request', + issues: bodyResult.error.issues, + message: 'Request body did not match the expected schema', + }, + 400, + ); + } + const action = bodyResult.data; + const outcome = await db.submitPage(idResult.data, action); + if (outcome.kind === 'not_found') + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + if (outcome.kind === 'conflict') + return c.json( + { + error: 'conflict', + message: 'Page was already submitted; create a new page if you need another submission', + }, + 409, + ); + metrics.pagesSubmitted.add(1); + metrics.pageSubmitLatency.record((Date.now() - outcome.createdAt.getTime()) / 1000); + return c.json({ ok: true }); +}; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd apps/api && npx vitest run app.test.ts -t "format echo"` +Expected: PASS — all four cases green. + +- [ ] **Step 7: Defer commit (Phase 1)** + +--- + +## Task 6: Phase 1 commit + +**Files:** all of Tasks 1–5. + +Now that the API + storage + sanitizer all hang together with green tests, commit Phase 1 as one atomic unit. + +- [ ] **Step 1: Run the full API test suite** + +Run: `cd /Users/netto/work/hackathons/gen-ui-sf/agent-ui-session/.claude/worktrees/keen-roentgen-e25c2a && npx vitest run apps/api` +Expected: All tests pass. No regressions in existing tests. + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` +Expected: No TypeScript errors. (If errors mention `Page` type missing `format`, recheck Task 1 Step 3.) + +- [ ] **Step 3: Stage and commit** + +Run from repo root: + +```bash +git add apps/api/db.ts apps/api/db.test.ts \ + apps/api/schemas.ts apps/api/schemas.test.ts \ + apps/api/store.ts \ + apps/api/app.ts apps/api/app.test.ts \ + apps/api/sanitize.ts apps/api/sanitize.test.ts \ + apps/api/package.json apps/api/mcp/tools.ts \ + package-lock.json +git commit -m "$(cat <<'EOF' +feat(api): add HTML page format alongside A2UI + +POST /new accepts an optional { format: "a2ui" | "html", spec } body. +Default remains "a2ui" — every existing client keeps working unchanged. +HTML payloads up to 1 MB are sanitized server-side (DOMPurify+jsdom via +isomorphic-dompurify) before storage, then echoed in GET /:id and +GET /:id/result so renderers and tools can route. POST /:id/result on +an HTML page returns 400 invalid_for_format — HTML is view-only. + +Schema: new `format` column on `pages`, NOT NULL DEFAULT 'a2ui' with +CHECK ('a2ui','html'). Added in init() with an idempotent ALTER TABLE +for existing deployments. + +Three defensive layers for HTML: sanitize -> CSP (renderer) -> iframe +sandbox (renderer). This commit lands the sanitizer; renderer work in +a follow-up commit on the same branch. + +Spec: docs/superpowers/specs/2026-05-15-html-format-design.md +EOF +)" +``` + +- [ ] **Step 4: Verify** + +Run: `git log --oneline -1 && git status` +Expected: New commit present. Working tree clean (no untracked or modified files). + +--- + +## Task 7: Add `show_html` MCP tool + sharpen `show_ui` + surface `format` in `check_result` + +**Files:** +- Modify: `apps/api/mcp/tools.ts` +- Modify: `apps/mcp/server.ts` (HTTP smoke / typing if needed) + +`show_ui` keeps its existing signature, gets a sharper description. New `show_html({ html: string })` tool. `check_result`'s `structuredContent` grows a `format` field. + +- [ ] **Step 1: Write the failing tests** + +Create or extend `apps/api/mcp/tools.test.ts` (if it exists, append; otherwise create with this header). The pattern: register the tools against a stub `PageOps` and assert the tool registration shapes. + +```ts +import { describe, it, expect } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerPagentTools, type PageOps } from './tools.ts'; + +function makeServer(): { server: McpServer; tools: Map unknown }> } { + const tools = new Map unknown }>(); + const server = { + registerTool(name: string, def: { description: string; inputSchema: unknown }, handler: (...args: unknown[]) => unknown) { + tools.set(name, { ...def, handler }); + }, + } as unknown as McpServer; + return { server, tools }; +} + +const noopOps: PageOps = { + showUi: async () => ({ id: 'a'.repeat(32), url: 'http://x/a', expires_at: 0 }), + showHtml: async () => ({ id: 'b'.repeat(32), url: 'http://x/b', expires_at: 0 }), + checkResult: async () => ({ kind: 'state', state: 'open', result: null, format: 'a2ui' }), +}; + +describe('registerPagentTools', () => { + it('registers three tools: show_ui, show_html, check_result', () => { + const { server, tools } = makeServer(); + registerPagentTools(server, noopOps); + expect(tools.has('show_ui')).toBe(true); + expect(tools.has('show_html')).toBe(true); + expect(tools.has('check_result')).toBe(true); + }); + + it('show_html description mentions view-only and no scripts', () => { + const { server, tools } = makeServer(); + registerPagentTools(server, noopOps); + const desc = tools.get('show_html')!.description; + expect(desc).toMatch(/view-only/i); + expect(desc).toMatch(/script/i); + expect(desc).toMatch(/JavaScript/i); + }); + + it('show_ui description distinguishes itself from show_html', () => { + const { server, tools } = makeServer(); + registerPagentTools(server, noopOps); + const desc = tools.get('show_ui')!.description; + expect(desc).toMatch(/show_html/); + }); + + it('check_result structuredContent includes format', async () => { + const { server, tools } = makeServer(); + registerPagentTools(server, noopOps); + const handler = tools.get('check_result')!.handler; + const out = (await handler({ page_id: 'a'.repeat(32) })) as { + structuredContent: { state: string; result: unknown; page_id: string; format: string }; + }; + expect(out.structuredContent.format).toBe('a2ui'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/api && npx vitest run mcp/tools.test.ts` +Expected: FAIL — `showHtml` not on `PageOps`, tool not registered, `format` not in structuredContent. + +- [ ] **Step 3: Extend `PageOps` and add `show_html` description** + +In `apps/api/mcp/tools.ts`, update the interface: + +```ts +export interface PageOps { + showUi(spec: unknown): Promise; + showHtml(html: string): Promise; + checkResult(page_id: string): Promise; +} +``` + +(`CheckResultOutcome` was already extended in Task 5 to include `format`.) + +Sharpen `SHOW_UI_DESCRIPTION` and add `SHOW_HTML_DESCRIPTION` and `SHOW_HTML_INPUT_DESCRIPTION`. Replace the descriptions block (lines 38–58) with: + +```ts +const SHOW_UI_DESCRIPTION = [ + "Ask the user a question that needs a structured answer back. Forms, pickers, confirmations, multi-step wizards, surveys, dashboards-as-input.", + "Returns { page_id, url, expires_at }. PRINT the URL so the user can open it. The agent never sees the user typing — only the final submitted result.", + "Each page is single-shot: one spec, one result. For a follow-up question, call show_ui again with a fresh spec — there is no surface-replace mechanism.", + "After this call, poll check_result on your own cadence to read the user response (start at 2-3s, back off exponentially up to ~30s; do other useful work between polls rather than blocking).", + "If you only want to SHOW something — a report, a chart, an infographic — use show_html instead. show_ui is for input.", +].join('\n\n'); + +const SHOW_UI_INPUT_DESCRIPTION = [ + 'A2UI v0.9 spec — an array of A2UI messages.', + 'Start with one createSurface, then updateComponents with a tree whose root component MUST have id "root".', + 'The basic catalog (https://a2ui.org/specification/v0_9/basic_catalog.json) provides Column, Row, Card, Text, TextField, Button, Checkbox, Image, Divider, List, Tabs, Slider.', + 'Buttons fire actions via { action: { event: { name, context } } }; bind input fields with { value: { path: "/key" } } and reference those paths in the button context so user input flows back.', + 'Keep specs small — one screen, one purpose.', +].join(' '); + +const SHOW_HTML_DESCRIPTION = [ + "Show the user a rich visualization: a styled report, dashboard, chart, infographic, comparison table, slide, or other view-only artifact.", + "Returns { page_id, url, expires_at }. PRINT the URL so the user can open it. The page is one-way — the user looks at it; nothing comes back.", + "Do NOT poll check_result for HTML pages; they never produce a result. If you need a follow-up decision, call show_ui after with a fresh spec.", + "Constraints (enforced — violations are stripped or rejected): no ', + }), + ); + expect(db.insertPage).toHaveBeenCalledOnce(); + const [page] = vi.mocked(db.insertPage).mock.calls[0]; + expect(page.format).toBe('html'); + expect(typeof page.spec).toBe('string'); + expect(page.spec as string).not.toContain('safe'); + }); + + it('rejects HTML payloads > 1 MB', async () => { + const big = 'a'.repeat(1_000_001); + const res = await app.fetch(req('POST', '/new', { format: 'html', spec: big })); + // bodyLimit middleware fires before Zod when body is over the absolute cap. + expect([400, 413]).toContain(res.status); + }); + + it('accepts A2UI payloads with implicit default format (backwards compat)', async () => { + const res = await app.fetch( + req('POST', '/new', { spec: [{ createSurface: { surfaceId: 'm' } }] }), + ); + expect(res.status).toBe(201); + }); + + it('rejects A2UI payloads > 256 KB even when body limit allows up to 1 MB', async () => { + // Use a value below the 1 MB bodyLimit but above the 256 KB A2UI cap. + const big = 'x'.repeat(300_000); + const res = await app.fetch(req('POST', '/new', { spec: big })); + expect(res.status).toBe(413); + const body = await json(res); + expect(body.error).toBe('payload_too_large'); + expect(body.format).toBe('a2ui'); }); }); @@ -196,6 +275,10 @@ describe('POST /:id/result', () => { // returned 409 "already submitted" for a page that was merely expired. // The fixed SELECT adds `expires_at > now()`, making expired rows return // 'not_found' → 404, which is the correct user-facing response. + // The handler reads the page first via getActivePage for the format guard; + // mock it to return an active a2ui page so submitPage's 'not_found' outcome + // is what we exercise here (e.g. row expired between the two reads). + (db.getActivePage as ReturnType).mockResolvedValueOnce(fakePage()); (db.submitPage as ReturnType).mockResolvedValueOnce({ kind: 'not_found' }); const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, validAction)); expect(res.status).toBe(404); @@ -205,6 +288,7 @@ describe('POST /:id/result', () => { it('returns 200 and calls db.submitPage when page is open', async () => { const page = fakePage(); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); (db.submitPage as ReturnType).mockResolvedValueOnce({ kind: 'ok', createdAt: new Date(), @@ -218,6 +302,7 @@ describe('POST /:id/result', () => { it('returns 409 on conflict (already submitted)', async () => { const page = fakePage({ state: 'submitted' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); (db.submitPage as ReturnType).mockResolvedValueOnce({ kind: 'conflict' }); const res = await app.fetch(req('POST', `/${page.id}/result`, validAction)); expect(res.status).toBe(409); @@ -229,6 +314,7 @@ describe('POST /:id/result', () => { it('409 conflict body.message mentions creating a new page', async () => { const page = fakePage({ state: 'submitted' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); (db.submitPage as ReturnType).mockResolvedValueOnce({ kind: 'conflict' }); const res = await app.fetch(req('POST', `/${page.id}/result`, validAction)); const body = await json(res); @@ -236,8 +322,11 @@ describe('POST /:id/result', () => { }); it('returns 400 for result body with name: "" (empty name)', async () => { - // Validation happens before db call, so no need to stub submitPage - const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, { name: '', surfaceId: 'x' })); + // Page must exist (a2ui) so we reach the body-parse stage. + (db.getActivePage as ReturnType).mockResolvedValueOnce(fakePage()); + const res = await app.fetch( + req('POST', `/${UNKNOWN_ID}/result`, { name: '', surfaceId: 'x' }), + ); expect(res.status).toBe(400); const body = await json(res); expect(body.error).toBe('bad_request'); @@ -253,18 +342,21 @@ describe('GET /:id/result', () => { (db.fetchAndAdvanceResult as ReturnType).mockResolvedValueOnce({ stateAtRead: 'open', result: null, + format: 'a2ui', }); const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`)); expect(res.status).toBe(200); const body = await json(res); expect(body.state).toBe('open'); expect(body.result).toBeNull(); + expect(body.format).toBe('a2ui'); }); it('returns submitted result after POST /:id/result', async () => { (db.fetchAndAdvanceResult as ReturnType).mockResolvedValueOnce({ stateAtRead: 'submitted', result: validAction, + format: 'a2ui', }); const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`)); expect(res.status).toBe(200); @@ -279,6 +371,7 @@ describe('GET /:id/result', () => { (db.fetchAndAdvanceResult as ReturnType).mockResolvedValueOnce({ stateAtRead: 'received', result: validAction, + format: 'a2ui', }); const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`)); expect(res.status).toBe(200); @@ -293,6 +386,72 @@ describe('GET /:id/result', () => { }); }); +// --------------------------------------------------------------------------- +// format echo + HTML result handling +// --------------------------------------------------------------------------- + +describe('format echo and HTML result handling', () => { + it('GET /:id echoes format=html for an HTML page', async () => { + const page = fakePage({ format: 'html', spec: '

x

' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); + const res = await app.fetch(req('GET', `/${page.id}`)); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.format).toBe('html'); + }); + + it('GET /:id echoes format=a2ui for an A2UI page', async () => { + const page = fakePage({ format: 'a2ui' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); + const res = await app.fetch(req('GET', `/${page.id}`)); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.format).toBe('a2ui'); + }); + + it('GET /:id/result includes format on every response', async () => { + (db.fetchAndAdvanceResult as ReturnType).mockResolvedValueOnce({ + stateAtRead: 'open', + result: null, + format: 'html', + }); + const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`)); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.format).toBe('html'); + expect(body.state).toBe('open'); + expect(body.result).toBeNull(); + }); + + it('POST /:id/result rejects HTML pages with 400 invalid_for_format', async () => { + const page = fakePage({ format: 'html', spec: '

x

' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); + const res = await app.fetch( + req('POST', `/${page.id}/result`, { name: 'submitted', surfaceId: 'main' }), + ); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.error).toBe('invalid_for_format'); + expect(body.format).toBe('html'); + expect(db.submitPage).not.toHaveBeenCalled(); + }); + + it('POST /:id/result still works for A2UI pages (regression)', async () => { + const page = fakePage({ format: 'a2ui' }); + (db.getActivePage as ReturnType).mockResolvedValueOnce(page); + (db.submitPage as ReturnType).mockResolvedValueOnce({ + kind: 'ok', + createdAt: new Date(), + }); + const res = await app.fetch( + req('POST', `/${page.id}/result`, { name: 'submitted', surfaceId: 'main' }), + ); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.ok).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // GET /health // --------------------------------------------------------------------------- @@ -479,6 +638,9 @@ describe('error handler', () => { }); it('submitPage throw on POST /:id/result returns 500', async () => { + // Page-existence gate runs first; mock it to return an a2ui page so + // the throw on submitPage is what we actually exercise. + (db.getActivePage as ReturnType).mockResolvedValueOnce(fakePage()); (db.submitPage as ReturnType).mockRejectedValueOnce(new Error('db write failed')); const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, validAction)); expect(res.status).toBe(500); diff --git a/apps/api/app.ts b/apps/api/app.ts index 5e86404..a65bba7 100644 --- a/apps/api/app.ts +++ b/apps/api/app.ts @@ -13,6 +13,7 @@ import * as db from './db.ts'; import * as store from './store.ts'; import { clientKey } from './client-key.ts'; import { env, pageIdSchema, newPageBodySchema, resultBodySchema } from './schemas.ts'; +import { sanitize } from './sanitize.ts'; import { logger } from './logger.ts'; import { metrics, statusClassFor } from './metrics.ts'; import type { RequestIdVariables } from './request-id.ts'; @@ -38,7 +39,11 @@ export const PUBLIC_URL = env.PUBLIC_URL ?? `http://localhost:${PORT}`; export const PAGE_TTL_MS = env.PAGE_TTL_MS; export const ALLOWED_ORIGINS = env.ALLOWED_ORIGINS; -export const MAX_BODY_BYTES = 256 * 1024; // 256 KB +// 1 MB is the HTML payload cap (per spec). The historical 256 KB cap for +// A2UI specs is enforced post-parse in newPageHandler so HTML payloads at the +// real cap pass through bodyLimit middleware untouched. +export const MAX_BODY_BYTES = 1_000_000; +export const A2UI_MAX_SPEC_BYTES = 256_000; const newPageLimiter = rateLimiter({ windowMs: env.RATE_LIMIT_WINDOW_MS, @@ -210,7 +215,37 @@ const newPageHandler = async (c: Context) => { 400, ); } - const created = await store.createPage(result.data.spec, { + const { format, spec } = result.data; + + if (format === 'a2ui') { + // Enforce the historical 256 KB cap on A2UI specs; HTML uses the full 1 MB. + // The bodyLimit middleware lets us inspect the parsed value here without + // double-paying for the read. + const serialized = JSON.stringify(spec ?? null); + if (serialized.length > A2UI_MAX_SPEC_BYTES) { + return c.json( + { + error: 'payload_too_large', + format: 'a2ui', + max_bytes: A2UI_MAX_SPEC_BYTES, + message: `A2UI spec exceeds the ${A2UI_MAX_SPEC_BYTES}-byte limit`, + }, + 413, + ); + } + } + + let storedSpec: unknown = spec; + if (format === 'html') { + const { output, removedTags, removedAttrs } = sanitize(spec as string); + getLog(c).info( + { format, removedTags, removedAttrs }, + 'sanitized html submission', + ); + storedSpec = output; + } + + const created = await store.createPage(storedSpec, format, { publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS, }); @@ -225,6 +260,7 @@ const getPageHandler = async (c: Context) => { if (!p) return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); return c.json({ spec: p.spec, + format: p.format, state: p.state, result: p.result, expires_at: p.expiresAt, @@ -235,6 +271,23 @@ const submitResultHandler = async (c: Context) => { const idResult = pageIdSchema.safeParse(c.req.param('id')); if (!idResult.success) return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + + // Format check happens before body parse to fail fast on HTML pages. HTML + // pages are view-only — there is no submit pipeline for them. + const page = await db.getActivePage(idResult.data); + if (!page) + return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); + if (page.format !== 'a2ui') { + return c.json( + { + error: 'invalid_for_format', + format: page.format, + message: `POST /:id/result is not supported for format=${page.format}; HTML pages are view-only`, + }, + 400, + ); + } + const raw = await c.req.json().catch(() => null); const bodyResult = resultBodySchema.safeParse(raw); if (!bodyResult.success) { @@ -271,7 +324,11 @@ const getResultHandler = async (c: Context) => { const outcome = await store.advanceResult(idResult.data); if (outcome.kind === 'not_found') return c.json({ error: 'not_found', message: 'Page not found or expired' }, 404); - return c.json({ state: outcome.state, result: outcome.result }); + return c.json({ + state: outcome.state, + result: outcome.result, + format: outcome.format, + }); }; // --- Routes ------------------------------------------------------------------ diff --git a/apps/api/db.test.ts b/apps/api/db.test.ts index 6a26b6e..b62aa77 100644 --- a/apps/api/db.test.ts +++ b/apps/api/db.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { withRetry, getActivePage } from './db'; +import type { Page, PageFormat } from './db'; describe('withRetry', () => { beforeEach(() => { @@ -122,6 +123,7 @@ describe('getActivePage retry semantics', () => { const fakeRow = { id: 'test-id', spec: { type: 'test' }, + format: 'a2ui' as const, state: 'open' as const, result: null, created_at: new Date(1000), @@ -152,6 +154,7 @@ describe('getActivePage retry semantics', () => { return { id: r.id, spec: r.spec, + format: r.format, state: r.state, result: r.result, createdAt: r.created_at.getTime(), @@ -175,3 +178,71 @@ describe('getActivePage retry semantics', () => { expect(typeof getActivePage).toBe('function'); }); }); + +// --------------------------------------------------------------------------- +// Page format column — structural tests (no live DB) +// --------------------------------------------------------------------------- +// Real DB round-trips are out of scope for the unit suite (postgres URL is a +// placeholder in vitest.config.ts). Instead, we assert that the type surface +// carries `format`, that a row projection including `format` produces a Page +// with the expected discriminator, and that PageFormat is the closed union. + +describe('Page format column (structural)', () => { + it('PageFormat is the closed union "a2ui" | "html"', () => { + const a: PageFormat = 'a2ui'; + const h: PageFormat = 'html'; + expect(a).toBe('a2ui'); + expect(h).toBe('html'); + // @ts-expect-error — only 'a2ui' | 'html' should typecheck. + const _bad: PageFormat = 'pdf'; + expect(_bad).toBe('pdf'); + }); + + it('Page accepts format and exposes it on the read shape', () => { + const p: Page = { + id: 'aabbccddeeff00112233445566778899', + spec: { foo: 1 }, + format: 'html', + state: 'open', + result: null, + createdAt: 1, + expiresAt: 2, + }; + expect(p.format).toBe('html'); + }); + + it('row projection from a select including `format` maps to Page.format', async () => { + // Mirrors getActivePage's mapping over the retry path. If the SELECT + // were to drop `format`, the projection would lose it; this regression + // test ensures the mapping is wired both ways. + type Row = { + id: string; + spec: unknown; + format: PageFormat; + state: 'open'; + result: unknown; + created_at: Date; + expires_at: Date; + }; + const fakeRow: Row = { + id: 'test-id', + spec: '
hi
', + format: 'html', + state: 'open', + result: null, + created_at: new Date(1000), + expires_at: new Date(2000), + }; + const projected: Page = { + id: fakeRow.id, + spec: fakeRow.spec, + format: fakeRow.format, + state: fakeRow.state, + result: fakeRow.result, + createdAt: fakeRow.created_at.getTime(), + expiresAt: fakeRow.expires_at.getTime(), + }; + expect(projected.format).toBe('html'); + expect(projected.spec).toBe('
hi
'); + }); +}); diff --git a/apps/api/db.ts b/apps/api/db.ts index 1108046..4e4377e 100644 --- a/apps/api/db.ts +++ b/apps/api/db.ts @@ -25,9 +25,12 @@ export async function withRetry(fn: () => Promise, opts: RetryOptions = {} export type PageState = 'open' | 'submitted' | 'received'; +export type PageFormat = 'a2ui' | 'html'; + export type Page = { id: string; spec: unknown; + format: PageFormat; state: PageState; result: unknown; createdAt: number; @@ -45,6 +48,7 @@ export async function init(connectionString: string): Promise { create table if not exists pages ( id text primary key, spec jsonb not null, + format text not null default 'a2ui' check (format in ('a2ui','html')), state text not null check (state in ('open','submitted','received')), result jsonb, created_at timestamptz not null default now(), @@ -53,6 +57,14 @@ export async function init(connectionString: string): Promise { received_at timestamptz ) `; + // Pick up the column on pre-existing deployments. Idempotent — safe to run + // on every boot. Backfill is implicit via the default. + await sql` + alter table pages + add column if not exists format text + not null default 'a2ui' + check (format in ('a2ui','html')) + `; await sql`create index if not exists pages_expires_at_idx on pages (expires_at)`; } @@ -75,6 +87,7 @@ function client(): ReturnType { type PageRow = { id: string; spec: unknown; + format: PageFormat; state: PageState; result: unknown; created_at: Date; @@ -85,7 +98,7 @@ export async function getActivePage(id: string): Promise { return withRetry(async () => { const c = client(); const rows = await c` - select id, spec, state, result, created_at, expires_at + select id, spec, format, state, result, created_at, expires_at from pages where id = ${id} and expires_at > now() `; @@ -94,6 +107,7 @@ export async function getActivePage(id: string): Promise { return { id: r.id, spec: r.spec, + format: r.format, state: r.state, result: r.result, createdAt: r.created_at.getTime(), @@ -150,13 +164,13 @@ export async function submitPage(id: string, action: unknown): Promise { +): Promise<{ stateAtRead: PageState; result: unknown; format: PageFormat } | null> { const c = client(); - const rows = await c<{ state: PageState; result: unknown }[]>` - select state, result from pages where id = ${id} and expires_at > now() + const rows = await c<{ state: PageState; result: unknown; format: PageFormat }[]>` + select state, result, format from pages where id = ${id} and expires_at > now() `; if (rows.length === 0) return null; - const { state, result } = rows[0]; + const { state, result, format } = rows[0]; const stateAtRead = state; if (state === 'submitted') { await c` @@ -165,14 +179,20 @@ export async function fetchAndAdvanceResult( where id = ${id} and state = 'submitted' `; } - return { stateAtRead, result }; + return { stateAtRead, result, format }; } export async function insertPage(p: Page): Promise { await withRetry(async () => { const c = client(); - await c`insert into pages (id, spec, state, expires_at) - values (${p.id}, ${c.json(p.spec as Parameters[0])}, 'open', to_timestamp(${p.expiresAt} / 1000.0))`; + await c`insert into pages (id, spec, format, state, expires_at) + values ( + ${p.id}, + ${c.json(p.spec as Parameters[0])}, + ${p.format}, + 'open', + to_timestamp(${p.expiresAt} / 1000.0) + )`; }); } diff --git a/apps/api/mcp/http.ts b/apps/api/mcp/http.ts index 83415aa..48c9944 100644 --- a/apps/api/mcp/http.ts +++ b/apps/api/mcp/http.ts @@ -75,7 +75,7 @@ function applyBaseHeaders(req: IncomingMessage, res: ServerResponse, requestId: export function buildInProcessOps(cfg: McpHttpConfig): PageOps { return { async showUi(spec) { - return store.createPage(spec, { + return store.createPage(spec, 'a2ui', { publicUrl: cfg.publicUrl, pageTtlMs: cfg.pageTtlMs, }); diff --git a/apps/api/mcp/tools.ts b/apps/api/mcp/tools.ts index cece50d..61dfea6 100644 --- a/apps/api/mcp/tools.ts +++ b/apps/api/mcp/tools.ts @@ -15,15 +15,21 @@ import { z } from 'zod'; export type PageState = 'open' | 'submitted' | 'received'; +export type PageFormat = 'a2ui' | 'html'; + export type ShowUiResult = { id: string; url: string; expires_at: number; }; +// `format` is required on responses from the in-process API path (which knows +// the format from the DB row) and optional on responses from the stdio +// adapter (which currently doesn't surface it — see Phase 2). Once the stdio +// path adds the field, this can be tightened to required. export type CheckResultOutcome = | { kind: 'not_found' } - | { kind: 'state'; state: PageState; result: unknown }; + | { kind: 'state'; state: PageState; result: unknown; format?: PageFormat }; export interface PageOps { showUi(spec: unknown): Promise; diff --git a/apps/api/package.json b/apps/api/package.json index 0dcd43f..540405d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "@scalar/hono-api-reference": "^0.10.14", "hono": "^4.6.14", "hono-rate-limiter": "^0.5.3", + "isomorphic-dompurify": "^2.16.0", "pino": "^10.3.1", "pino-opentelemetry-transport": "^3.0.0", "postgres": "^3.4.9", diff --git a/apps/api/sanitize.test.ts b/apps/api/sanitize.test.ts new file mode 100644 index 0000000..4a2273d --- /dev/null +++ b/apps/api/sanitize.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { sanitize } from './sanitize.ts'; + +describe('sanitize', () => { + it('returns clean payloads unchanged (idempotent on safe input)', () => { + const safe = '

Hello

World !

'; + expect(sanitize(safe).output).toBe(safe); + }); + + it('preserves inline
hi
'; + expect(sanitize(input).output).toContain(' +

Q3 Engineering Report

+
142
PRs merged
+
11
Bugs closed
+
3
Outages
+

The team shipped 142 pull requests this quarter…

+""" +show_html(html) +``` + +Print the returned URL. Move on — no polling. + ## Setup expectation These tools talk to the hosted `pagent` REST service at `https://pagent.up.railway.app` by default. Set the `PAGENT_URL` env var to point at a self-hosted instance (e.g. `http://localhost:8787` if you're running the repo locally). From ecd0dcb6f95a354863403fda484b1c0b37ad4330 Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Fri, 15 May 2026 15:06:38 -0700 Subject: [PATCH 08/11] chore: prettier-ignore design plan/spec docs Plan and spec markdown files contain TypeScript pseudo-code blocks that prettier mis-parses into semantically-broken output (e.g. `{ a: 'x' | 'y', b }` parses as a comma expression). The docs were author-formatted and committed as such in 7a5c0c5 and a7cb4c7. Add docs/superpowers/{plans,specs}/ to .prettierignore so the format:check gate stays green without rewriting the docs. --- .prettierignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.prettierignore b/.prettierignore index 88af386..67a4f0f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,8 @@ apps/web/vendor/ apps/mcp/server.bundle.js apps/web/.vercel/ package-lock.json +# Plan/spec documents contain embedded TypeScript pseudo-code that prettier +# mis-parses into semantically-broken output (e.g. `{ a: 'x' | 'y', b }` is +# parsed as a comma expression). Keep them author-formatted. +docs/superpowers/plans/ +docs/superpowers/specs/ From 439c8a5cca0fcdecfb6b400c2a16d2965e070950 Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Fri, 15 May 2026 15:13:52 -0700 Subject: [PATCH 09/11] fix(web): address Phase 3 code review (banner role, viewport flex, base attr) --- apps/web/html-renderer.test.ts | 2 +- apps/web/html-renderer.ts | 4 ++-- apps/web/main.ts | 36 ++++++++++++++++++++++------------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/web/html-renderer.test.ts b/apps/web/html-renderer.test.ts index ef5c2f8..70dc94b 100644 --- a/apps/web/html-renderer.test.ts +++ b/apps/web/html-renderer.test.ts @@ -26,7 +26,7 @@ describe('buildScaffoldedHtml', () => { it('injects the agent body inside ', () => { const out = buildScaffoldedHtml('

hello

'); - expect(out).toMatch(/

hello<\/p><\/body>/); + expect(out).toMatch(/\s*

hello<\/p>\s*<\/body>/); }); it('does not interpret the agent body as a template (no double-encoding)', () => { diff --git a/apps/web/html-renderer.ts b/apps/web/html-renderer.ts index c22d247..d3674e5 100644 --- a/apps/web/html-renderer.ts +++ b/apps/web/html-renderer.ts @@ -27,7 +27,7 @@ export function buildScaffoldedHtml(sanitizedHtml: string): string { - + ${sanitizedHtml} @@ -51,6 +51,6 @@ export function createSandboxedIframe(sanitizedHtml: string): HTMLIFrameElement iframe.setAttribute('loading', 'lazy'); iframe.title = 'Agent-generated content'; iframe.srcdoc = buildScaffoldedHtml(sanitizedHtml); - iframe.style.cssText = 'width:100%;height:100vh;border:0;display:block'; + iframe.style.cssText = 'flex:1 1 auto;width:100%;border:0;display:block;min-height:0'; return iframe; } diff --git a/apps/web/main.ts b/apps/web/main.ts index 631bfe9..14c81fc 100644 --- a/apps/web/main.ts +++ b/apps/web/main.ts @@ -33,6 +33,10 @@ const POLL_MAX_MS = 30_000; const POLL_BACKOFF_FACTOR = 2; const POLL_TIMEOUT_MS = 60_000; +// TODO: make REPORT_EMAIL configurable via VITE_REPORT_EMAIL once self-hosters need it. +// Until then, abuse reports for pagent.vercel.app route to the project maintainer. +const REPORT_EMAIL = 'alex@blockful.io'; + class AgentUIApp extends SignalWatcher(LitElement) { static properties = { status: { state: true }, @@ -173,6 +177,12 @@ class AgentUIApp extends SignalWatcher(LitElement) { .html-chrome-report:hover { color: var(--fg, #1b1b1b); } + .html-stack { + display: flex; + flex-direction: column; + height: 100vh; + min-height: 0; + } `; declare status: 'connecting' | 'live' | 'closed' | 'error'; @@ -425,18 +435,20 @@ class AgentUIApp extends SignalWatcher(LitElement) { `; } return html` -

- ${this.htmlIframe()} +
+ + ${this.htmlIframe()} +
`; } From e2a3170cf5d977b241b6a6fdb6c5ca561cb4159f Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Fri, 15 May 2026 15:20:18 -0700 Subject: [PATCH 10/11] docs: fix OpenAPI error schemas for HTML format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review feedback caught three OpenAPI drifts: - Error400.enum was missing `sanitized_empty` (returned by POST /new on HTML payloads stripped to empty by the sanitizer); added it plus a `format` field that surfaces on both `invalid_for_format` and `sanitized_empty` rejections. - Error413 still referenced the old 262 144-byte cap and lacked the `format` field that the API includes on every 413 response; updated to A2UI 256 000 / HTML 1 000 000 and added the `format` discriminator. - The 413 response description on POST /new restated the same caps with guidance on which one triggered. No behavior change — these match what the API already does in apps/api/app.ts (250-253, 224-232). --- docs/openapi.yaml | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 46771ab..672bd94 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -237,9 +237,10 @@ paths: $ref: '#/components/schemas/Error400' '413': description: | - Request body exceeds the service limit. Reduce the spec size. - The service caps the body at `max_bytes` bytes (currently 262 144). - Simplify or paginate your spec and retry. + Request body exceeds the service limit. A2UI specs cap at + 256 000 bytes (post-parse); HTML payloads cap at 1 000 000 bytes + (wire body limit). The response's `format` field tells you which + cap fired so you can size the next submission appropriately. headers: X-Request-ID: $ref: '#/components/headers/XRequestId' @@ -249,7 +250,9 @@ paths: $ref: '#/components/schemas/Error413' example: error: payload_too_large - max_bytes: 262144 + format: a2ui + max_bytes: 256000 + message: A2UI spec exceeds the 256 KB limit; use format "html" for larger payloads only when appropriate '429': description: | Per-IP rate limit exceeded. Wait `retry_after_seconds` seconds @@ -687,11 +690,21 @@ components: properties: error: type: string - enum: [bad_request, invalid_for_format] + enum: [bad_request, invalid_for_format, sanitized_empty] description: | `bad_request` — body is malformed or fails schema validation. `invalid_for_format` — operation is not supported for this page's format (e.g. POSTing a result to an HTML page). + `sanitized_empty` — `POST /new` with `format: "html"` whose payload + was reduced to empty by the sanitizer (only forbidden tags like + `