src/agents.rs 8.1 KiB 154 lines Raw
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 `&amp;` 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 }