Repository navigation
feat(web): migrate web worker from TypeScript to Rust #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
1010af0
feat(web): scaffold standalone Rust crate with --manifest
andersonleal 3dde93d
feat(web): add WebConfig with defaults, schema, json roundtrip
andersonleal 6e80ca1
feat(web): SSRF blocklist (ipnet) + parse_target
andersonleal 786cff3
refactor(web): hoist SSRF blocklists into OnceLock statics
andersonleal ce1b531
feat(web): check_target with DNS resolve, literal-IP short-circuit, f…
andersonleal e25acb2
feat(web): FetchPayload wire schema, enums, validation, tool description
andersonleal 15f27e6
feat(web): HTML→markdown (htmd) + text extractor (tl) + depth guard
andersonleal 466152f
feat(web): fetch.rs pure helpers (timeout/bytes/json/auth-strip/encode)
andersonleal 39f8caf
feat(web): byte-capped streaming reader (truncation terminal)
andersonleal 9317c12
feat(web): execute_fetch request loop + IP pinning + redirects + page…
andersonleal bc29d4a
feat(web): register web::fetch with Value input, validation, panic is…
andersonleal b1f73ba
feat(web): configuration-worker integration + main boot wiring
andersonleal 80bc588
chore(web): log config-RPC retries and seed-read I/O errors
andersonleal 27b8eff
test(web): wiremock integration tests for fetch behaviors + image env…
andersonleal 22b5281
test(web): adversarial SSRF blocking + IP-pinning tests
andersonleal 860e494
chore(web): finalize migration — cleanup comments/imports, clippy clean
andersonleal 684ac45
refactor(web): run HTML transform on spawn_blocking
andersonleal 1d425a2
style(web): apply cargo fmt
ytallo 283b6c3
docs(web): add worker README
ytallo c3ff5dc
chore(web): track Cargo.lock
ytallo 4a54a0f
fix(web): address CodeRabbit review on PR #295
andersonleal 70f2966
ci(release): include the web worker in the release pipeline
andersonleal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| [workspace] | ||
|
|
||
| [package] | ||
| name = "web" | ||
| version = "1.0.0" | ||
| edition = "2021" | ||
| publish = false | ||
|
|
||
| [[bin]] | ||
| name = "iii-web" | ||
| path = "src/main.rs" | ||
|
|
||
| [lib] | ||
| name = "web" | ||
| path = "src/lib.rs" | ||
|
|
||
| [dependencies] | ||
| iii-sdk = "=0.19.2" | ||
| arc-swap = "1" | ||
| tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "net"] } | ||
| serde = { version = "1", features = ["derive"] } | ||
| serde_json = "1" | ||
| serde_yaml = "0.9" | ||
| anyhow = "1" | ||
| thiserror = "2" | ||
| tracing = "0.1" | ||
| tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } | ||
| clap = { version = "4", features = ["derive"] } | ||
| async-trait = "0.1" | ||
| schemars = "0.8" | ||
| futures = "0.3" | ||
| reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2"] } | ||
| htmd = "0.5" | ||
| tl = { package = "astral-tl", version = "0.7" } | ||
| ipnet = "2" | ||
| base64 = "0.22" | ||
| bytes = "1" | ||
|
|
||
| [dev-dependencies] | ||
| serde_json = "1" | ||
| tempfile = "3" | ||
| wiremock = "0.6" | ||
| tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "net"] } | ||
| futures = "0.3" | ||
| bytes = "1" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # web | ||
|
|
||
| Outbound HTTP(S) client on the [iii engine](https://github.com/iii-hq/iii) | ||
| bus. A single bus function, `web::fetch`, performs a network request and | ||
| returns a parsed `{ ok, status, headers, body }` envelope with enforced | ||
| size/timeout caps and server-side SSRF protection. It is the network | ||
| counterpart to the `shell` worker: use `web::fetch` instead of | ||
| `shell::exec` with `curl`/`wget` for any HTTP request. | ||
|
|
||
| The worker is a faithful Rust port of the original TypeScript `web::fetch` | ||
| implementation — same request fields and success/error/image envelopes, so | ||
| existing callers and the harness consumer are unaffected. | ||
|
|
||
| ## Table of contents | ||
|
|
||
| 1. [Install](#install) | ||
| 2. [Configuration](#configuration) | ||
| 3. [The `web::fetch` function](#the-webfetch-function) | ||
| 4. [Reading the result](#reading-the-result) | ||
| 5. [Page-reading mode](#page-reading-mode) | ||
| 6. [SSRF guard](#ssrf-guard) | ||
| 7. [Local development & testing](#local-development--testing) | ||
|
|
||
| --- | ||
|
|
||
| ## Install | ||
|
|
||
| ```bash | ||
| iii worker add web | ||
| ``` | ||
|
|
||
| `iii worker add` fetches the binary (`iii-web`), writes a config block into | ||
| `~/.iii/config.yaml`, and the engine starts the worker on the next | ||
| `iii start`. | ||
|
|
||
| --- | ||
|
|
||
| ## Configuration | ||
|
|
||
| The worker reads its operational ceilings from the `configuration` worker | ||
| under the `web` key. It registers a schema + seed on boot, fetches the | ||
| authoritative value, and hot-reloads on change. All keys are optional and | ||
| fall back to the defaults below. | ||
|
|
||
| ```yaml | ||
| web: | ||
| default_timeout_ms: 30000 # per-request timeout when caller omits timeout_ms | ||
| max_timeout_ms: 120000 # ceiling; caller timeout_ms is clamped DOWN to this | ||
| default_response_bytes: 262144 # default body cap in page-reading mode (256 KiB) | ||
| max_response_bytes: 5242880 # absolute body ceiling (5 MiB); caller max_bytes clamped to this | ||
| max_transform_bytes: 1048576 # HTML→markdown/text runs only on bodies ≤ this (1 MiB) | ||
| max_redirects: 5 # hops before too_many_redirects | ||
| user_agent: 'iii-harness/0.1 (+web::fetch)' | ||
| allow_loopback: true # set false in prod so 127.0.0.0/8, ::1, localhost are blocked | ||
| ``` | ||
|
|
||
| A copy of these defaults lives in [`config.yaml.example`](./config.yaml.example). | ||
|
|
||
| --- | ||
|
|
||
| ## The `web::fetch` function | ||
|
|
||
| Minimal call — a URL is the only required field; everything else has a | ||
| default: | ||
|
|
||
| ```jsonc | ||
| // agent_trigger { function: "web::fetch", payload: { "url": "https://api.github.com/zen" } } | ||
| // → { "ok": true, "status": 200, "body": "Design for failure.", ... } | ||
| ``` | ||
|
|
||
| ### Request fields | ||
|
|
||
| | Field | Default | Notes | | ||
| |---|---|---| | ||
| | `url` *(required)* | — | absolute `http(s)://` | | ||
| | `method` | `GET` | case-insensitive; GET HEAD POST PUT PATCH DELETE OPTIONS | | ||
| | `headers` | `{}` | object; keys as written | | ||
| | `json` | — | structured payload; auto-stringified + sets `content-type: application/json`. **Wins over `body`.** | | ||
| | `body` | — | raw string body; ignored on GET/HEAD | | ||
| | `response_format` | `"text"` | `"text"` \| `"base64"` (binary) \| `"json"` (also parses into `json`) | | ||
| | `format` | — | page-reading mode: `"markdown"` \| `"text"` \| `"html"` (see below) | | ||
| | `timeout_ms` | `default_timeout_ms` | clamped DOWN to `max_timeout_ms` | | ||
| | `max_bytes` | `max_response_bytes` (`default_response_bytes` in `format` mode) | over-cap body is truncated, not errored | | ||
| | `follow_redirects` | `true` | each hop re-checked against the SSRF blocklist | | ||
|
|
||
| ### Response | ||
|
|
||
| **Success** (`ok: true`) — returned for *any* completed response, 2xx through 5xx: | ||
|
|
||
| ```jsonc | ||
| { | ||
| "ok": true, | ||
| "status": 200, | ||
| "status_text": "OK", | ||
| "headers": { "content-type": "application/json", ... }, // keys LOWER-CASED | ||
| "body": "<utf8 text | base64 when response_format=base64>", | ||
| "json": { ... }, // only when response_format="json" and parse succeeded | ||
| "parse_error": "…", // only when response_format="json" and JSON.parse failed | ||
| "response_format": "json", | ||
| "bytes_truncated": false, // true when body hit max_bytes (NOT an error) | ||
| "redirect_chain": ["https://…/a"], // omitted when no redirects | ||
| "content_type": "text/html", // page-reading mode only | ||
| "transformed": "markdown" // only when an HTML transform actually ran | ||
| } | ||
| ``` | ||
|
|
||
| **Failure** (`ok: false`) — the fetch never produced a response (no `status`): | ||
|
|
||
| ```jsonc | ||
| { "ok": false, "error": "blocked_host", "message": "…" } | ||
| ``` | ||
|
|
||
| | `error` | Cause | Fix | | ||
| |---|---|---| | ||
| | `invalid_payload` | Payload failed schema (bad `method`, wrong types) | Correct the named fields | | ||
| | `invalid_url` | `url` isn't a parseable absolute `http(s)://` URL | Pass a full absolute URL incl. scheme | | ||
| | `blocked_host` | Target resolves to a private / link-local / cloud-metadata IP | Don't target internal/metadata hosts; for dev loopback set `allow_loopback` | | ||
| | `timeout` | Slower than `timeout_ms` | Raise `timeout_ms` (up to ceiling) or shrink work via `max_bytes` | | ||
| | `too_many_redirects` | More than `max_redirects` hops | Use the final URL, or `follow_redirects: false` and read the `location` header | | ||
| | `transport_error` | Connection refused/reset, TLS/DNS failure, or unparseable redirect `Location` | Check host/port/cert; retry if transient | | ||
|
|
||
| There is **no `too_large` error** — oversize responses come back `ok: true` | ||
| with `bytes_truncated: true`. | ||
|
|
||
| --- | ||
|
|
||
| ## Reading the result | ||
|
|
||
| **`ok` tells you if the request COMPLETED, not whether the server was happy.** | ||
|
|
||
| - A 404 or 500 is a **successful fetch** → `ok: true`, `status: 404`. Branch on `status` for HTTP outcomes. | ||
| - `ok: false` means the request never produced a response. Branch on `error`. | ||
|
|
||
| ```jsonc | ||
| if (!r.ok) → fetch failed; look at r.error | ||
| else if (r.status>=400) → server returned an HTTP error; look at r.status / r.body | ||
| else → success; use r.body or r.json | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Page-reading mode | ||
|
|
||
| `response_format` (transport encoding) and `format` (page-reading) are | ||
| orthogonal — set one, not both. When `format` is set, `response_format` is | ||
| ignored. | ||
|
|
||
| - **`format: "markdown"`** — `text/html` → Markdown (the right default for reading pages; far fewer tokens than raw HTML). | ||
| - **`format: "text"`** — `text/html` → plain text. | ||
| - **`format: "html"`** — raw HTML. | ||
|
|
||
| In page-reading mode the request goes out with a browser User-Agent + | ||
| format-matched `Accept` header, and retries once with the honest UA on a | ||
| Cloudflare `cf-mitigated: challenge` response. The HTML→markdown/text | ||
| transform runs only on bodies ≤ `max_transform_bytes`; larger pages come | ||
| back raw with `transformed` unset. | ||
|
|
||
| **Images:** a viewable `image/*` response (jpeg/png/gif/webp, 2xx, | ||
| complete, non-empty) returns the actual image plus a one-line text summary | ||
| instead of the JSON envelope. Anything else falls back to the normal | ||
| envelope with `response_format: "base64"`. | ||
|
|
||
| --- | ||
|
|
||
| ## SSRF guard | ||
|
|
||
| DNS is resolved **once**, every resolved address is checked, then the | ||
| request is dialed at the **validated IP** (pinned `lookup` + TLS | ||
| `servername`) — so the IP that passed the check is the IP connected to (no | ||
| DNS-rebind window). On each 3xx the `Location` is re-resolved and | ||
| re-validated before following, and `Authorization` / `Cookie` / | ||
| `Proxy-Authorization` are stripped when the redirect changes host or | ||
| downgrades `https → http`. | ||
|
|
||
| Blocked **unconditionally** → `error: "blocked_host"`: | ||
|
|
||
| - Private RFC1918 (`10/8`, `172.16/12`, `192.168/16`) | ||
| - Link-local incl. **cloud metadata** `169.254.169.254` | ||
| - IPv6 ULA (`fc00::/7`), link-local (`fe80::/10`), multicast | ||
| - `::ffff:`-mapped IPv4 in both dotted (`::ffff:169.254.169.254`) and hex (`::ffff:a9fe:a9fe`) forms | ||
|
|
||
| Loopback (`127.0.0.0/8`, `::1`, `localhost`) is **allowed by default** for | ||
| dev convenience; operators set `web.allow_loopback: false` for prod, after | ||
| which loopback returns `blocked_host`. | ||
|
|
||
| --- | ||
|
|
||
| ## Local development & testing | ||
|
|
||
| ```bash | ||
| cargo build # build the iii-web binary | ||
| cargo test # unit + wiremock integration + adversarial SSRF tests | ||
| cargo clippy -- -D warnings # lints | ||
| cargo fmt --check # formatting | ||
| ``` | ||
|
|
||
| The agent-facing authoring guide lives in | ||
| [`skills/index.md`](./skills/index.md) (served at runtime via | ||
| `directory::skills::get`). For the exact, authoritative field types, call | ||
| `engine::functions::info { function_id: "web::fetch" }` — the live schema | ||
| wins if it ever disagrees with this document. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| fn main() { | ||
| println!( | ||
| "cargo:rustc-env=TARGET={}", | ||
| std::env::var("TARGET").unwrap() | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| web: | ||
| default_timeout_ms: 30000 | ||
| max_timeout_ms: 120000 | ||
| default_response_bytes: 262144 | ||
| max_response_bytes: 5242880 | ||
| max_transform_bytes: 1048576 | ||
| max_redirects: 5 | ||
| user_agent: 'iii-harness/0.1 (+web::fetch)' | ||
| allow_loopback: true | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| iii: v1 | ||
| name: web | ||
| language: rust | ||
| deploy: binary | ||
| manifest: Cargo.toml | ||
| bin: iii-web | ||
| description: Outbound HTTP client on the iii bus (web::fetch). | ||
|
|
||
| dependencies: | ||
| configuration: "^0.19.0" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Default
allow_loopbackshould befalsefor secure-by-default SSRF posture.Enabling loopback in the example config makes localhost access the default behavior, which weakens the guardrail for operators who copy this file as-is.
Suggested change
📝 Committable suggestion
🤖 Prompt for AI Agents