1 //! Agent surface (m3, f0rge issue #8): `agents.html` + `agents.md`.2 //!3 //! Static instructions for agents: read via raw/api/tarball/bundle, submit4 //! via the two inbox POSTs. The markdown source is written verbatim to5 //! `dist/agents.md`; `dist/agents.html` is the same text rendered through6 //! [`crate::build::markdown_to_html`].7 //!8 //! The curl examples POST to the exact m4 route paths `/inbox/issue` and9 //! `/inbox/pr-prompt` (the m4 server implements those paths; keep the10 //! examples in sync). License text is quoted verbatim from `DRAFT.md`.11 //!12 //! SECURITY: all untrusted values (site name, clone URL, branch, sha) go13 //! through [`crate::layout::esc`]; the guide body is static text.14 15 use std::path::Path;16 17 use crate::build::markdown_to_html;18 use crate::layout::{esc, page_site_with_provenance};19 20 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).21 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,22 /// and storage all derive from that one constant (no drift by construction).23 pub use crate::inbox::LICENSE_TEXT;24 25 fn agents_markdown(26 site_name: &str,27 clone_url: Option<&str>,28 branch: &str,29 short_sha: &str,30 ) -> String {31 let clone_line = match clone_url {32 Some(u) => format!("Clone: `git clone {u}` (branch `{branch}` @ `{short_sha}`)."),33 None => format!("No clone URL configured; branch `{branch}` @ `{short_sha}`."),34 };35 format!(36 "# Agents guide — {site_name}\n\n\37 You are an agent visiting a hu13 mirror of one repo. No accounts, no JS, no auth.\n\38 Read with curl; suggest with two dumb text POSTs. A human (or their agent) triages later.\n\n\39 ## Read\n\n\40 - Site: `{site_name}`, {clone_line}\n\41 - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\42 - JSON API (compact fixed-order, same data as HTML): `/api/issues.json`, `/api/pulls.json`, `/api/commits.json`, `/api/releases.json`, `/api/wiki.json`.\n\43 - Clone: `git clone <origin>/repo.git` (dumb-HTTP, works on any static server) or `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\44 - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\45 - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\46 - Human pages mirror the same data: `/commits.html`, `/commit/<sha>.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\47 ## Submit (the only write path)\n\n\48 Exactly two text-only POSTs on the built-in server (static site has forms at `/new-issue.html` and `/new-pr.html`; both are free textboxes):\n\n\49 - `POST /inbox/issue` — suggest an issue. Fields: `title` + `body`.\n\50 - `POST /inbox/pr-prompt` — suggest a change. Fields: `title` + `prompt`.\n\n\51 Both POSTs are `Content-Type: application/x-www-form-urlencoded` (urlencoded-only; `multipart/*` is rejected), UTF-8 only.\n\n\52 | field | rule |\n\53 | ----- | ---- |\n\54 | `title` | required, plain text, <=300 chars |\n\55 | `body` / `prompt` | required, plain text, <=20000 chars |\n\56 | `handle` | optional, plain text, <=40 chars |\n\57 | `contact` | optional, plain text, <=100 chars |\n\58 | `license` | required checkbox — must be checked, else `400` |\n\59 | honeypot | `website` field must be left empty (filled = silently dropped, still says received) |\n\60 | whole request | <=32KB else `413` |\n\n\61 On success the server shows a `received` page with a one-time ack uuid (shown once, not in a URL, no lookup endpoint) and reflects nothing back (kills reflected XSS). No GET, no listing, no read-back. Inbox never appears in the site; accepted text shows up later as a real issue/PR.\n\n\62 ### License (required — check the box only if you agree)\n\n\63 > {license}\n\n\64 Stored per submission: type, title, body, optional handle/contact, license version, `received_at`, and a salted source-IP hash. The checkbox is validated before storing; no submission is accepted without it.\n\n\65 ## Philosophy\n\n\66 - Issues = detailed actionable text from your user. Write what a maintainer can act on: context, steps, expected vs actual, links to `/raw/` paths.\n\67 - PRs = ONE prompt not code. Send a single plain-text prompt describing the change; never send files, diffs, branches, or bundles. Code words inside stay text — never applied, never executed.\n\68 - Free textboxes: the forms are free text on purpose — visiting agents are smarter than any template. Keep it dumb, keep it text.\n\n\69 ## Curl examples\n\n\70 Suggest an issue:\n\n\71 ```sh\n\72 curl -X POST http://localhost:39313/inbox/issue \\\n\73 --data-urlencode \"title=Crash on empty config\" \\\n\74 --data-urlencode \"body=Steps to reproduce: ... (detailed actionable text from your user)\" \\\n\75 --data-urlencode \"handle=my-bot\" \\\n\76 --data-urlencode \"[email protected]\" \\\n\77 --data-urlencode \"license=agree\" \\\n\78 --data-urlencode \"website=\"\n\79 ```\n\n\80 Suggest a PR prompt (ONE prompt, not code):\n\n\81 ```sh\n\82 curl -X POST http://localhost:39313/inbox/pr-prompt \\\n\83 --data-urlencode \"title=Retry with backoff\" \\\n\84 --data-urlencode \"prompt=In the uploader, retry failed puts with exponential backoff and ... (ONE prompt, not code)\" \\\n\85 --data-urlencode \"handle=my-bot\" \\\n\86 --data-urlencode \"[email protected]\" \\\n\87 --data-urlencode \"license=agree\" \\\n\88 --data-urlencode \"website=\"\n\89 ```\n\n\90 Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",91 site_name = site_name,92 clone_line = clone_line,93 branch = branch,94 license = LICENSE_TEXT.replace('\n', "\n> "),95 )96 }97 98 /// Render `agents.html` (rendered guide) + `agents.md` (raw markdown copy).99 pub fn render(100 out: &Path,101 site_name: &str,102 nav: &str,103 clone_url: Option<&str>,104 branch: &str,105 short_sha: &str,106 description: Option<&str>,107 ) -> Result<(), String> {108 let site_e = esc(site_name);109 let branch_e = esc(branch);110 let sha_e = esc(short_sha);111 let url_e = clone_url.map(esc);112 let md = agents_markdown(&site_e, url_e.as_deref(), &branch_e, &sha_e);113 // NOTE: site_name/branch/sha above are pre-escaped for the markdown114 // source so the raw `agents.md` and rendered `agents.html` agree. The115 // markdown renderer escapes raw HTML again, so `&` etc. stay safe.116 let html_body = markdown_to_html(&md);117 let html = page_site_with_provenance(118 &format!("agents — {site_name}"),119 nav,120 &html_body,121 site_name,122 description,123 branch,124 short_sha,125 );126 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;127 std::fs::write(out.join("agents.html"), html)128 .map_err(|e| format!("hu13: write agents.html: {e}"))?;129 std::fs::write(out.join("agents.md"), &md)130 .map_err(|e| format!("hu13: write agents.md: {e}"))?;131 Ok(())132 }133 134 #[cfg(test)]135 mod tests {136 use super::*;137 138 #[test]139 fn curl_examples_hit_m4_routes() {140 let md = agents_markdown("test", None, "main", "abc1234");141 assert!(md.contains("/inbox/issue"), "issue POST path present");142 assert!(143 md.contains("/inbox/pr-prompt"),144 "pr-prompt POST path present"145 );146 assert!(md.contains("application/x-www-form-urlencoded"));147 assert!(md.contains("<=300"));148 assert!(md.contains("<=20000"));149 assert!(md.contains("<=32KB"));150 assert!(md.contains("ONE prompt not code") || md.contains("ONE prompt"));151 assert!(md.contains("detailed actionable text"));152 assert!(md.contains("By submitting this text"));153 }154 }