commit - /dev/null
commit + 6b55003318869d06a4f7b148d2bf0649d22053ac
blob - /dev/null
blob + b7432a22b56a53536c4f3e1a6774a14d76af77d4 (mode 644)
--- /dev/null
+++ .gitignore
+# Build artifacts (objects + depfiles live in-tree next to sources)
+*.o
+*.d
+*.core
+
+# Per-subsystem build output dirs (regress uses obj/)
+obj/
+
+# Generated parser (yacc from common/parse.y)
+/src/parent/parse.c
+/src/parent/parse.h
+/regress/parse/parse.c
+/regress/parse/parse.h
+
+# Built worker binaries
+/src/parent/fugu
+/src/ui/fugu-ui
+/src/net/fugu-net
+/src/exec/fugu-exec
+/src/fetch/fugu-fetch
+
+# Regress test binaries
+regress/*/*_test
+
+# Release tarball staging (make dist)
+/fugu-*.tar.gz
blob - /dev/null
blob + 1c2ac7eccd5ba9586a9fc21a7cc462c1c3e0b78e (mode 644)
--- /dev/null
+++ .scratch/api-hardening/README.md
+# API hardening sweep: kill the length/NUL bug class at the contract level
+
+Status: done (sweep landed 2026-06-26; deferred items below)
+Origin: audit/fix session 2026-06-26 — `git log --grep audit` and the workflow output at `~/.claude/.../tasks/w3ojf5irm.output` for the history.
+
+## Outcome (2026-06-26)
+
+All six sub-tasks landed. Regress total grew from 660 to 687 checks across 21 suites, 0 failures.
+
+- **1 (buf contract):** `buf_puts` → `buf_puts_cstr` (81 call sites in 19 files); added `buf_terminate` + `buf_cstr` (assert-on-default-build); migrated every `buf_putc(x, '\0'); x.len--;` site (28+ sites across src + regress); new `regress/buf/` suite with 23 checks covering terminate/cstr/embedded-NUL round-trip.
+- **2 (obj_getstr_n promote):** added as public `obj_getstr_n` in `src/common/json.{c,h}`; the static copy in `fetch_run.c` removed; fetch_run.c now uses the common one. The wider migration of `obj_getstr` callers in `openai.c` / `anthropic.c` / `exec_run.c` / `skills.c` was AUDITED but most sites are dispatch-only (strcmp on enum-like strings) or feed `json_kv_string` (itself strlen-based — a separate migration). See "Deferred" below.
+- **3 (json_tok_strdup doc):** header comment in `json.h` steers wire/storage callers to `_n`.
+- **4 (tlsconn audit):** `tls_error()` is called only inside `tlsconn_dup_error`; the file-level contract is now documented inline. No new tls_error leaks.
+- **5 (ACL tests):** four host-match boundary checks added to `regress/fetch/fetch_test.c` — exact-pattern rejects "evillocalhost" vs "localhost"; dot-suffix accepts "localhost" vs ".localhost"; dot-suffix rejects "evillocalhost" vs ".localhost" (the label-anchor regression).
+- **6 (secret_load):** new `secret_load(have_inline, val, have_file, path, &out, &len)` in `parent.c`; `netprov_key` returns the true byte length; `delegate_net` and the Kagi path in `delegate_fetch` now `freezero(buf, keylen)` instead of `strlen` — so a file-loaded key with embedded NUL bytes is wiped completely. Old `resolve_secret` kept as a thin back-compat wrapper.
+
+## Deferred
+
+- Wider `obj_getstr` → `obj_getstr_n` migration in `exec_run.c` (paths/commands flowing into `open(2)`, `argv`), `openai.c` (tool name/description into `json_kv_string`), and `anthropic.c` (error message). Most sites need both `_n` AND a length-aware sink (`json_kv_string_n` etc.) — a separate sub-sweep.
+- Migration of remaining `obj_getstr` callers whose sinks are themselves strlen-based (`json_kv_string`, `buf_puts_cstr`). The producer fix has marginal value without the sink fix.
+- NUL-validation guards at the security boundary (exec_run.c paths/commands): explicit `if (strlen(s) != s_len) reject;` after `obj_getstr_n`. Not a behavior regression today (the strlen sink truncates uniformly), but the right defense.
+
+## Goal
+
+The audit and fix session closed a class of correctness/safety bugs caused by
+mixing length-aware producers with strlen-based consumers (the canonical case
+was `on_tool_call` in `net_api.c` reading past `buf->len` via strlen, splicing
+heap garbage into outbound JSON; siblings included `openai.c` accumulating
+streamed tool args via `buf_puts`, and `fetch_run.c` using `strlen` for HTTP
+body length on `json_tok_strdup` output that can carry embedded NULs).
+
+This issue tightens the **API contracts** so that future code physically
+cannot reintroduce the same shape. Six sub-tasks below; do them in order or
+land them piecewise — none blocks another beyond what's noted.
+
+## What's already done (do not redo)
+
+- `json_tok_strdup_n(d, tok, &lenp)` exists in `src/common/json.{c,h}` and is
+ used by the openai, anthropic, and fetch_run fixes.
+- An ad-hoc `obj_getstr_n` is **static** inside `src/fetch/fetch_run.c` — its
+ shape is the prototype for the promoted version (see task 2).
+- `acl_path_prefix_match(path, pref, preflen)` is a **static** helper in
+ `src/fetch/fetch_run.c` (around line 1054, see commit) — promote/extend it
+ in task 5.
+- `tlsconn_connect`'s `*errstr` already takes ownership (caller `free()`s) —
+ task 4 is about encapsulating libtls error access more cleanly, not the
+ ownership change itself.
+
+## Sub-tasks
+
+### 1. `struct buf` contract: make NUL-termination an explicit, asserted invariant
+
+The bug we hit was that `b->data[b->len]` can be uninitialized after
+`buf_puts`. The post-fix convention is "callers explicitly NUL-terminate
+when they later read `.data` as a C string." Bake that into the API.
+
+Concrete changes in `src/common/buf.{c,h}`:
+
+- Rename `buf_puts(b, s)` → `buf_puts_cstr(b, s)` so its strlen semantics are
+ loud at every call site. Mechanical sed-then-review.
+- Add `void buf_terminate(struct buf *b)` that does
+ `buf_putc(b, '\0'); b->len--;` (the pattern fugu now uses everywhere). Use
+ it at `on_tool_call`, `convo_add_user`, etc. for symmetry.
+- Add a debug-only helper:
+ `static inline const char *buf_cstr(const struct buf *b)` that
+ `assert(b->data[b->len] == '\0')` then returns `b->data`. Migrate the
+ `json_raw(&w, b.data)` callers to `json_raw(&w, buf_cstr(&b))`. Under
+ `FUGU_DEBUG=1` this would have caught the original overrun at first hit.
+
+Verification: `make FUGU_DEBUG=1 regress` still passes (existing tests
+already exercise the converted call sites); `make` (default) is unchanged.
+
+### 2. Promote `obj_getstr_n` to a shared primitive
+
+Currently a static helper in `fetch_run.c`. Many callers across `openai.c`,
+`anthropic.c`, `net_run.c`, `parent.c`, `confload.c`, etc. still use the
+strlen-based `obj_getstr` (or hand-rolled equivalents) on model/user input
+that can carry embedded NULs.
+
+- Add `char *obj_getstr_n(const struct json_doc *d, int obj, const char *key,
+ size_t *lenp)` to `src/common/json.{c,h}` (alongside `json_tok_strdup_n`).
+- Grep `obj_getstr(` and audit each call site:
+ - if the caller hands the string to a strlen-based writer that crosses the
+ network or storage boundary → migrate to `obj_getstr_n`
+ - if the caller only uses the string for equality / dispatch (`strcmp` on
+ a small enum) → leave it (NUL semantics are equivalent)
+- Specifically check: `src/common/anthropic.c` (audit flagged it as
+ "probably has the same pattern but wasn't fully read" — confirm or fix);
+ `src/exec/exec_run.c` (paths and shell commands — NULs here are
+ exploitable on the exec side, treat carefully).
+
+### 3. Deprecate `json_tok_strdup` once callers are migrated
+
+After task 2 completes, the only remaining strlen-based callers should be
+ones that legitimately don't care about embedded NULs. Document this:
+
+- Add a comment to `json_tok_strdup` in `json.h` saying "prefer
+ `json_tok_strdup_n` when the decoded bytes flow into a wire format or
+ storage; this strlen-truncating variant is for dispatch/equality only."
+- Optional: add `__attribute__((__deprecated__(...)))` if you want compile-
+ time pressure to migrate. Probably overkill given the small caller list.
+
+### 4. `tlsconn`: confine libtls error pointer to the module
+
+`tlsconn_connect` already returns an owned `*errstr`. But the underlying
+borrow-then-free pattern is intrinsic to libtls and could resurface in any
+new `tlsconn_*` function (e.g. a future `tlsconn_renegotiate`, or if someone
+exposes `tlsconn_get_state`). Encapsulate once:
+
+- Add `static char *tlsconn_dup_error(struct tls *ctx)` in `tlsconn.c` —
+ already added by the fix workflow; promote it to a small documented
+ helper inside the file (keep it static — it's an internal contract).
+- Audit `tlsconn.c`: any function that captures a `tls_error()` pointer for
+ return must go through `tlsconn_dup_error` BEFORE the next `tls_free` or
+ `tls_close`. The fix workflow handled `tlsconn_connect`'s three call sites
+ + the early `tls_client failed` literal; verify nothing else has crept in.
+
+### 5. ACL: route every path/host check through one helper
+
+`fetch_run.c:acl_path_prefix_match` exists. Sister checks (host match,
+deny-list, future allow-list extensions) should all go through similar
+named helpers so the segment-boundary rule, case-folding, and any future
+normalization live in ONE place:
+
+- Add `static int acl_host_match(const char *host, const char *pat)` if not
+ already factored — currently `acl_host_match` inlines the dot-suffix
+ logic. Trust but verify it's not vulnerable to the same off-by-one
+ pattern (audit said it isn't, but a unit test under
+ `regress/fetch/fetch_test.c` codifying the rule is worth having).
+- Consider normalizing patterns at config-load time: strip trailing slashes
+ on path patterns, lowercase host patterns. Push the normalization into
+ `confload.c` so runtime checks are simpler.
+
+### 6. `resolve_secret`: length-aware secret loading
+
+Current `resolve_secret` in `src/parent/parent.c` returns a `char *` and
+`explicit_bzero` happens at strlen boundary in callers. For keys with
+embedded NULs (rare but legitimate for raw-bytes credentials) the trailing
+bytes leak past `bzero`.
+
+- Add `int secret_load(const char *path, char **out, size_t *outlen)` that
+ returns (ptr, len) so callers can `freezero(buf, len)` correctly.
+- Use it in `delegate_net` / `delegate_fetch`. The M_SECRET payload already
+ carries a length over imsg, so net/fetch end is fine.
+- Keep `resolve_secret` as a thin wrapper around `secret_load` until all
+ callers migrate, then remove.
+
+## Constraints
+
+- **OpenBSD KNF** is the tiebreaker on style (the user has this as a
+ feedback memory: prefer base-system idioms when in doubt).
+- **No new dependencies.** Stay within the existing toolchain.
+- **Privsep contract is sacred.** No new path between workers; no new
+ imsg variant unless the existing M_TOOL_* / M_CONFIG / M_SECRET set
+ cannot model the change.
+- **Test-first where the cost is low.** Each task should land with a
+ regress test in the appropriate `regress/<subsystem>/` dir. The audit
+ fix workflow's tests are the template.
+
+## Verification
+
+- `make` clean (no warnings, default flags)
+- `make FUGU_DEBUG=1` clean (UBSan-trap + Fortify + -Wextra -Wformat=2)
+- `make -C regress regress` — all dirs pass, total check count ≥ 870
+- A focused regress test exists for each migration that introduces a new
+ invariant (e.g. task 1 should add a `regress/buf/` if not present and
+ test that `buf_terminate` + `buf_cstr` round-trips)
+- The audit summary in the session output (`w5m6qp6ti.output`) should
+ re-run with zero confirmed findings against the modified files
+
+## Out of scope
+
+- Adding ASan/MSan support. OpenBSD's clang ships no runtime; UBSan-trap is
+ what's portable. Don't try to graft an ASan port.
+- Refactoring providers (anthropic.c, openai.c) beyond the embedded-NUL
+ migration. The agent-loop/streaming design is sound.
+- The skills feature. Skills landed in two prior phases; the contract is
+ stable. Hands off unless a task above touches a function skills uses,
+ in which case re-run the skills regress (`regress/skills/`).
blob - /dev/null
blob + 4636cfdab272ae9b5379d2c75cbd02651695bfb9 (mode 644)
--- /dev/null
+++ .scratch/fetch-tls-refactor/README.md
+# fetch_run.c: route TLS through `tlsconn` instead of open-coding libtls
+
+Status: done (2026-06-26)
+Origin: /code-review ultra finding #12, 2026-06-26 session.
+
+## Outcome (2026-06-26)
+
+http_fetch now attaches TLS via `tlsconn_attach` instead of open-coding `tls_client` / `tls_configure` / `tls_connect_socket` / `tlsconn_do_handshake`. Real libtls error reasons now surface to the user (e.g. "TLS handshake failed: certificate verification failed: certificate has expired" instead of the historical canned literal "TLS handshake failed"). All four `tlsconn_*` hardening properties — owned `*errstr` strings, the `tlsconn_dup_error` chokepoint, the centralised handshake retry loop, and the lifecycle invariants under heap churn — now protect fetch the same way they protect net.
+
+Total regress 705 → 710 (0 failures), +5 from the new `tlsconn_attach` test.
+
+### New tlsconn primitive
+
+`tlsconn_attach(struct tlsconn *, struct tls_config *, int fd, const char *servername, char **errstr)` complements `tlsconn_connect`. fetch needs it because its `dial()` does the TCP connect under its own SSRF guard + wall-clock deadline; `tlsconn_connect` (which dials internally via libtls) would silently bypass both. Ownership of `fd` transfers to the `tlsconn` on success; on failure `c.fd` stays -1 and the caller still owns the fd.
+
+### fetch_run.c changes
+
+- `http_fetch` signature: `const char **errstr` → `char **errstr` (caller-owned heap string).
+- `dial` signature: same flip — `*errstr` is heap-allocated at every failure site.
+- All `*errstr = "literal"` writes migrated to `*errstr = xstrdup("literal")`.
+- TLS section reduced to: `if (tlsconn_attach(&c, fs->tlscfg, fd, host, &tlserr) == -1) { compose; free(tlserr); goto out; }`.
+- `out:` label uses `tlsconn_close(&c)` (idempotent; handles the never-attached case too).
+- Three callers (`do_web_search`, `do_web_fetch`, `do_http_request`) updated to `char *err = NULL` and `free(err)` after the buf_printf.
+
+### New regress
+
+`test_attach_caller_keeps_fd_on_failure` in `regress/tlsconn/`: caller dials a stub that immediately closes; `tlsconn_attach` returns -1 with heap-owned `*errstr` (heap-stable under churn); `c.fd` stays -1; caller's fd is still its responsibility to close. Codifies the asymmetric-ownership invariant between `tlsconn_connect` and `tlsconn_attach`.
+
+### Verified
+
+- `make` clean
+- `make FUGU_DEBUG=1` clean (UBSan-trap + Fortify + Wextra + Wformat=2)
+- `make regress` — 710 checks, 0 failures across all 22 suites
+- Adversarial workflow review: 3 lenses (correctness / memory-leaks / KNF), all `verdict: ok`. Cosmetic fixes from the review applied.
+
+### Live verification (deferred)
+
+The handoff's Verification section calls for a hit against an expired-cert host to see the new error text. Defer until the user reinstalls the port (`/usr/local/bin/fugu` is still on the previous build). Suggested live test once reinstalled:
+
+```
+/web_fetch https://expired.badssl.com/
+```
+
+Before: `web_fetch: expired.badssl.com: TLS handshake failed`
+After: `web_fetch: expired.badssl.com: TLS handshake failed: certificate verification failed: certificate has expired`
+
+## Goal
+
+`http_fetch` in `src/fetch/fetch_run.c` (around line 822) calls `tls_client`, `tls_configure`, `connect_socket`, `tls_handshake` directly, instead of going through the `tlsconn` module that net_api uses. Consequences:
+
+1. Every TLS error in the fetch worker surfaces a canned string literal (`"TLS handshake failed"`, etc.) instead of the real `tls_error(c.ctx)` reason. Users can't tell a cert verify failure from a hostname mismatch from a connect-time DNS error.
+2. Any future hardening of `tlsconn_connect` — SNI policy, cert pinning, OCSP, the recent owned-errstr ownership change — silently bypasses fetch.
+3. The use-after-free bug class the audit fixed in `tlsconn_connect` could re-emerge here: `tls_error()` returns a borrowed pointer; if fetch_run captures it before `tls_close()`/`tls_free()`, it has the same lifetime hazard.
+
+Route `http_fetch` through `tlsconn` so the contract is enforced in one place.
+
+## Why this matters
+
+Today the fetch worker handles:
+
+- Kagi search (`web_search`)
+- Web page fetch with HTML→text rendering (`web_fetch`)
+- General HTTP request to allowlisted hosts (`http_request`)
+
+All three share the `http_fetch` helper. All three see degraded error messages on TLS failure and don't benefit from any tlsconn hardening.
+
+This is pre-existing (not a regression from this session), but: the audit hardened `tlsconn_connect`'s `*errstr` ownership; with fetch open-coding TLS, that hardening only protects net, not fetch. Anyone touching fetch's TLS path has to remember to keep it in sync with tlsconn.
+
+## What's already done
+
+- `tlsconn_connect` returns an owned, heap-allocated `*errstr` (`char **`, caller frees) — landed in the audit-fix workflow.
+- `tlsconn.c` has `tlsconn_dup_error` (static helper) centralising the libtls error capture.
+- API hardening handoff documents the broader pattern (`.scratch/api-hardening/README.md` task 4).
+
+This handoff is the concrete refactor that picks up that work.
+
+## Approach
+
+Migrate `http_fetch` (and any sister helpers in fetch_run.c that call libtls directly) to:
+
+1. `tlsconn_client_config(cafile)` for the cert config (already shared)
+2. `tlsconn_connect(&c, cfg, host, port, &err)` for connect+handshake
+3. `tlsconn_write_all(&c, ...)` for the request
+4. `tlsconn_read(&c, ...)` in the response loop
+5. `tlsconn_close(&c)` on cleanup
+
+Match the shape `net_api.c:net_api_call` uses (lines 169-180). On `tlsconn_connect` returning -1, free the owned `err` string after emitting it.
+
+The CA bundle path lives in netstate as `cafile` and in fetchstate as a parallel field — make sure fetch's tls_config equivalent uses the same path semantics.
+
+## Concrete starting points
+
+- `src/fetch/fetch_run.c:822` — start of `http_fetch`, the function to migrate
+- `src/fetch/fetch_run.c` — search for `tls_client`, `tls_configure`, `tls_handshake`, `tls_close`, `tls_free` to find all sites
+- `src/common/tlsconn.{c,h}` — the API to use
+- `src/net/net_api.c:127-232` — `net_api_call` is the working example; mirror its idiom
+- `src/fetch/fetch.c` and `src/fetch/fetch_run.h` — fetchstate definition; check whether `cafile` and `tlscfg` are already plumbed (similar to net's `netstate.tlscfg`)
+
+## Constraints
+
+- OpenBSD KNF
+- Preserve the existing `FETCH_MAXBODY` bound on response size
+- Preserve pledge promises: fetch worker pledges `stdio inet dns`; tlsconn calls don't open files (the CA bundle is loaded before pledge tightens — confirm)
+- Don't introduce a new imsg variant; the existing M_TOOL_REQUEST/M_TOOL_RESULT/M_ERROR set is sufficient
+- The error message format that bubbles up to M_ERROR should now include the real libtls reason — update the `net_emit_error` calls accordingly so the user-facing diagnostic is informative
+
+## Verification
+
+- `make` and `make FUGU_DEBUG=1` clean
+- `make regress` — fetch tests pass (currently the harness doesn't inject TLS failures; see the `tls-regress-harness` handoff)
+- Manual test against a host with an expired or mismatched cert: confirm the M_ERROR text now contains a libtls-quality reason rather than the generic literal
+- After the `tls-regress-harness` work lands, add a fetch-side test that injects a TLS failure and asserts the M_ERROR carries the real reason
+- Live: run `/web_fetch https://expired.badssl.com` (or equivalent) and check the user sees a useful error
+
+## Out of scope
+
+- Replacing the HTTP layer in fetch with the one in `src/common/http.c` (net's). They serve different purposes (fetch streams body for HTML→text conversion); leave the HTTP framing alone, change only the TLS plumbing.
+- Refactoring the Kagi-specific code path. Kagi is just a host on top of `http_fetch`; the migration helps it transparently.
+- Adding HSTS/cert-pinning policy. That belongs in `tlsconn_client_config` and lives in the API hardening sweep.
blob - /dev/null
blob + 5008bfc0ddcb51cebdf6ed3e7d44a3aa54b88d41 (mode 644)
--- /dev/null
+++ .scratch/journal-turn-txn/README.md
+# Journal transactions: failed turns must not leave orphan records
+
+Status: ready-for-agent
+Origin: /code-review ultra finding #11, 2026-06-26 session.
+
+## Goal
+
+When a turn fails mid-flight (HTTP error from the provider, network drop, malformed stream), the in-memory convo is truncated back to a mark — but the JOURNAL already contains the records the turn wrote BEFORE submitting (skill marker, synth assistant tool_use, synth user tool_result, and any partial assistant message blocks). On resume those records replay, putting the convo in a state the user never actually experienced and the model never actually responded to.
+
+This is most visible with the Phase 2 skills synth pair (three records in flight), but the same shape applies to any future "write-then-submit" turn.
+
+Add transactional boundaries to the journal so resume can faithfully reproduce ONLY the turns that succeeded.
+
+## Why this matters
+
+Today after a failure mid-skill:
+
+- In-memory convo: rolled back to before the synth pair.
+- Journal: `[marker, assistant(tool_use), user(tool_result)]` — orphan trio.
+- Next turn the user types "Hello", journal appends a normal user message.
+- Resume replay: the orphan trio gets converted back to convo state, then the user("Hello") follows.
+- The next API request goes out with `[…, assistant(tool_use), user(tool_result), user("Hello")]` — two adjacent user messages, which Anthropic Messages strict-rejects, and OpenAI tolerates but treats as confused context.
+
+Skills magnify this because Phase 2 records three messages per invocation; pre-Phase-2 a failed turn left a single orphan user message that mostly didn't break alternation.
+
+## What's already done
+
+- The synth pair shape (skills_synth_invocation produces `user(typed) + assistant(tool_use) + user(tool_result)`) — landed in the review-fix workflow.
+- convo_replay now renders tool_result blocks — same workflow.
+
+The orphan-on-failure case was deferred from that workflow because it needs a journal protocol change, not a localised fix.
+
+## Approach
+
+Add a `{"fugu":"turn_failed"}` marker to the journal record vocabulary. On failure, emit the marker right after the partial turn's records. On resume, when the replay walker sees `turn_failed`, scan backwards through journal records until it hits the most recent transactional boundary (a skill marker, a `/clear` marker, a `/compact` marker, or the start of the journal) and DROP all records in between.
+
+This treats every skill marker and every regular user-message boundary as an implicit "begin transaction"; turn_failed is the abort signal.
+
+### Concrete shape
+
+In `src/ui/convo.c`:
+
+- Add `RK_TURN_FAILED` to `enum rec_kind`.
+- Add `convo_marker_turn_failed(struct buf *out)` to emit `{"fugu":"turn_failed"}`.
+- In `record_kind`, recognise the new marker.
+- In `resume_line`, when seeing `RK_TURN_FAILED`:
+ - Walk backwards through `rc->cv` removing the last K messages (free them) up to and including the marker that opened the failed transaction. The marker is the closest previous record matching `RK_SKILL` OR a "user typed" boundary. **Note:** since record_kind only sees one line at a time, you'll need a richer replay state — track the convo-position-at-last-boundary in `struct resume_ctx`, and on TURN_FAILED truncate to that saved position.
+- After the truncation, the next non-marker record reopens a new transaction.
+
+In `src/ui/ui.c` and `src/ui/ui_curses.c`:
+
+- After a failed turn (the `r == 1` branch where we already do `convo_truncate(cv, mark)`), also append a `turn_failed` marker to the journal.
+
+In `src/ui/convo.h` (the header for the new functions).
+
+### Wire format
+
+Use `{"fugu":"turn_failed"}` — symmetric with `/clear` and `/compact`. No payload needed since the marker semantically means "undo from the previous boundary."
+
+## What's been ruled out
+
+- **"Just don't journal until success."** The streamed assistant message records arrive INTERLEAVED with the turn; they must journal eagerly or you lose them on crash. Buffering the whole turn defeats the append-only-as-it-happens design.
+- **"Reorder so the response comes BEFORE the synth pair."** Can't — the response is generated from those messages.
+- **"Make the synth pair atomic in the imsg protocol."** Wrong layer; this is a journal/resume concern, not an imsg concern.
+
+## Concrete starting points
+
+- `src/ui/convo.c:241` — `enum rec_kind` definition
+- `src/ui/convo.c:84-107` — pattern for marker emitters (`convo_marker_clear`, `convo_marker_compact`)
+- `src/ui/convo.c:278-310` — `resume_line` switch where the new case lands
+- `src/ui/ui.c:373-381` — the `r == 1` failure branch where `convo_truncate(cv, mark)` runs today
+- `src/ui/ui_curses.c` — equivalent path inside the M_TURN_DONE / M_ERROR handlers
+
+## Constraints
+
+- OpenBSD KNF style
+- Backward compatibility: old journals (no turn_failed markers, possibly with orphans) must still replay without crashing — just produce the buggy convo they always produced. Don't auto-clean retroactively.
+- Resume's CP_INFO display: emit something like "previous failed turn discarded" so the user sees what happened.
+
+## Verification
+
+- A new regress test under `regress/resume/`: write a journal with marker + synth pair + turn_failed, then a fresh user message. Replay. Assert the convo contains only the fresh user message, and a CP_INFO was emitted.
+- Live: invoke `/smoke` against a deliberately broken provider (e.g. temporarily set an invalid API key), watch fugu error, then exit, then `fugu -c` to resume. The resumed convo should NOT contain the failed skill invocation.
+- `make regress` total count rises by the new tests; all dirs green.
+
+## Out of scope
+
+- A general transaction API for arbitrary multi-record sequences. This is a focused fix for the skill-synth + failed-turn case.
+- Crash recovery (process killed mid-write). The journal is append-only with O_APPEND; partial lines are handled by the existing parser.
blob - /dev/null
blob + 384391355dd860b0b922abe4da40ccc8b1c1cefc (mode 644)
--- /dev/null
+++ .scratch/tls-regress-harness/README.md
+# TLS regress harness: failure injection + use-after-free regression test
+
+Status: done (2026-06-26)
+Origin: audit session 2026-06-26 — the tlsconn use-after-free fix landed
+without a regress test because the existing harness only exercises the
+happy path. See `src/common/tlsconn.c:tlsconn_connect` (already fixed) and
+`regress/net/net_test.c` for the existing stub TLS server.
+
+## Outcome (2026-06-26)
+
+New `regress/tlsconn/` suite. 18 checks, all passing against the fixed code. Total regress 687 → 705 (0 failures across 22 suites).
+
+Tests landed:
+
+- **Mode 3 (connect-to-nothing):** `tlsconn_connect` against an unbound port. Asserts `*errstr` is non-NULL, non-empty, length stable across heap churn, byte-stable across heap churn, and that `c.fd == -1` / `c.ctx == NULL` post-failure.
+- **Mode 2 (TCP-accept-then-close):** fork()ed stub binds 127.0.0.1:0, accepts, immediately closes. tls_handshake fails inside libtls; same heap-churn assertions on the captured error string.
+- **close-on-uninitialised:** `tlsconn_close` on a memset-zeroed `struct tlsconn` is a clean no-op and idempotent.
+- **repeated-failures-distinct-pointers:** Two back-to-back `tlsconn_connect` failures produce two errstr allocations with distinct heap pointers but identical message bytes — rules out a future regression that returns a shared static buffer.
+
+Heap churn primitive: 32 rounds × 64KB allocations, each filled with `0xA5` then freed, forcing OpenBSD's default malloc.conf junk-fill (`0xDB`) to overwrite any region a stale `tls_error()` pointer might have referenced.
+
+## UAF regression validation (the proof)
+
+The handoff asked for a revert-the-fix experiment to prove the test catches the original bug. Done:
+
+- Replaced the three `tlsconn_dup_error(c->ctx)` calls in `tlsconn_connect`'s fail paths with the pre-fix borrowed-pointer assignment `*errstr = (char *)tls_error(c->ctx)`.
+- Rebuilt + ran `make regress` → process aborted with **SIGABRT** (from a CHECK macro tripping an internal libc consistency check on the corrupted heap region).
+- Restored the fix → 18 checks pass cleanly.
+
+The test cleanly distinguishes the fixed implementation from the buggy one. The exact failure mode (SIGABRT vs CHECK-FAIL vs hang) varies between runs because OpenBSD's heap-junk pattern depends on free/alloc order — but the test ALWAYS detects abnormal behavior with the UAF in place.
+
+## What's already done
+
+- The use-after-free in `tlsconn_connect` is fixed: `*errstr` is now
+ `xstrdup`-allocated and caller-owned. Signature in `tlsconn.h` is
+ `char **errstr` (not `const char **`); contract documented in the
+ header comment around line 39.
+- Callers in `src/net/net_api.c` (lines ~169 and ~273) `free(err)` after
+ `net_emit_error` consumes it.
+- A static `tlsconn_dup_error(struct tls *ctx)` helper in `tlsconn.c`
+ centralises the libtls error capture pattern. See API hardening issue
+ task 4 for completing that encapsulation.
+
+## Goal
+
+Add failure injection to the regress harness for libtls failure modes,
+then write a focused regression test for the use-after-free that was just
+fixed in `tlsconn_connect`. The test should fail loudly against the
+pre-fix code (read-after-free of the libtls error buffer) and pass on the
+post-fix code.
+
+The harness should be reusable: any future TLS robustness work — cert
+rotation, peer verify errors, partial handshakes — can lean on the same
+injection primitives.
+
+## Why this matters
+
+The use-after-free at `tlsconn.c:70` (pre-fix) was confirmed real by three
+adversarial verifiers but stayed latent because:
+
+- OpenBSD's default `malloc.conf` junk-fills freed memory, so the
+ `tls_error()` buffer USUALLY still looks valid after `tls_free()` returns
+- The existing regress only exercises happy-path TLS (`regress/net/net_test.c`
+ stub serves valid responses)
+- The error string ends up in an M_ERROR message displayed to the user, so
+ garbage would be obvious — but only in the rare connect-failure flow
+
+Codified test coverage prevents the next analogous mistake from sleeping
+in production.
+
+## What's already done (do not redo)
+
+- The use-after-free in `tlsconn_connect` is fixed: `*errstr` is now
+ `xstrdup`-allocated and caller-owned. Signature in `tlsconn.h` is
+ `char **errstr` (not `const char **`); contract documented in the
+ header comment around line 39.
+- Callers in `src/net/net_api.c` (lines ~169 and ~273) `free(err)` after
+ `net_emit_error` consumes it.
+- A static `tlsconn_dup_error(struct tls *ctx)` helper in `tlsconn.c`
+ centralises the libtls error capture pattern. See API hardening issue
+ task 4 for completing that encapsulation.
+
+## Approach
+
+The existing harness at `regress/net/net_test.c` runs:
+
+- `H` (this process) acting as parent+UI
+- `N` (forked) running `net_loop` — talks to S over libtls
+- `S` (forked) a stub libtls server with a self-signed cert and canned response
+
+To inject failures, `S` (or a sibling stub) needs modes:
+
+### Mode 1 — Reject at TLS handshake (cert mismatch)
+
+S serves a cert whose subject CN does not match the host string N tries to
+connect to. Or a cert signed by a CA that N hasn't been configured to
+trust. Either path should make `tls_handshake` fail; `tlsconn_connect`'s
+fail label runs, `*errstr` should be a valid heap string after return.
+
+### Mode 2 — Drop connection mid-handshake
+
+S accepts the TCP connection but closes before completing TLS. Surfaces as
+a `tls_error` from the libtls state machine. Same expectation on `*errstr`.
+
+### Mode 3 — Connect-to-nothing
+
+H configures N to talk to a port that nothing is listening on. `tls_connect`
+fails at the TCP layer. Different libtls error message, same lifecycle.
+
+## Concrete starting points
+
+- `regress/net/net_test.c` — the existing stub harness. Lines that fork the
+ stub server, generate the cert, drive H's expectations. Reuse the
+ cert-generation Makefile target.
+- `regress/net/Makefile` — has the openssl/cert step. Either factor it out
+ or duplicate for a new `regress/tlsconn/` if the failure-injection tests
+ don't need a real `net_loop`.
+- `src/common/tlsconn.c` — fixed code under test. Note the three `fail`
+ labels at lines ~70, ~74, ~78 and the early literal at line ~66.
+- `src/common/tlsconn.h:39-47` — the documented contract that the test
+ asserts against.
+- `src/net/net_api.c:140` (declares `char *err`), `:169-175` (fail path
+ with the `free(err)`), `:247`, `:273-278` — the two caller patterns.
+
+## Test outline
+
+Recommended location: a new `regress/tlsconn/` dir, mirroring
+`regress/json/` (which links the C source directly without forking a
+worker). Avoid net_test's full N+S+H choreography unless you specifically
+want to exercise the M_ERROR path.
+
+```c
+/* Pseudocode -- adapt to the harness idiom */
+
+static void
+test_tlsconn_connect_fails_with_owned_errstr(void)
+{
+ struct tls_config *cfg = tlsconn_client_config(NULL); /* default */
+ struct tlsconn c;
+ char *err = NULL;
+ int rc;
+
+ /* Mode 3 is the cheapest -- no stub server, no cert. */
+ rc = tlsconn_connect(&c, cfg, "127.0.0.1", "1", /* unbound port */ &err);
+
+ CHECK(rc == -1, "connect to unbound port fails");
+ CHECK(err != NULL, "errstr populated on failure");
+ CHECK(strlen(err) > 0, "errstr is non-empty");
+ /* The key assertion: poison the heap, then read err. On the buggy
+ * code this would have been pointing into freed libtls memory and
+ * the bytes would no longer match. On the fixed code we own the
+ * memory so it's stable. */
+ explicit_bzero_then_resurrect_test_heap();
+ CHECK(strlen(err) > 0, "errstr still valid after heap churn");
+
+ free(err);
+ tls_config_free(cfg);
+}
+```
+
+For Modes 1 and 2 add a small stub server inline (the cert-generation in
+`regress/net/Makefile` is reusable verbatim — split it into a regress-wide
+make include).
+
+## Constraints
+
+- **OpenBSD KNF** style.
+- **No real network.** Use loopback + ports the kernel picks (bind to
+ port 0, query the OS for the assigned port — the same pattern
+ net_test.c uses).
+- **Sanitizer-friendly.** The test must pass under `FUGU_DEBUG=1` (UBSan
+ trap). If you trigger a TLS error path that has a known UBSan signal
+ in libtls itself, document it and skip rather than mask.
+- **No new tlsconn API.** Test against the existing contract. If a new
+ helper is genuinely needed, file a follow-up for API hardening task 4.
+
+## Verification
+
+- `make -C regress/tlsconn regress` — the new test runs and passes
+- Revert the fix on a scratch branch (restore `*errstr = tls_error(c->ctx)`
+ in `tlsconn.c`), rebuild, re-run — the test should fail. Document the
+ observed failure mode in the commit message. (Don't ship the revert.)
+- `make -C regress regress` — total check count rises by the count this
+ test adds, all dirs still green
+- `make FUGU_DEBUG=1 regress` — the same, under UBSan trap
+
+## Out of scope
+
+- Verifying every libtls function fugu uses. Focus on the failure paths
+ that hit the documented contract (`tlsconn_connect`'s `*errstr`).
+- Mocking the entire libtls API. Use real libtls against a stub server;
+ that's how net_test.c works and it's the right scope.
+- Touching `src/net/net_api.c` callers. They've been updated; the test is
+ unit-level on tlsconn.c.
blob - /dev/null
blob + 5d698d507c141ae6341126006fb465c715709410 (mode 644)
--- /dev/null
+++ AGENTS.md
+# AGENTS.md
+
+## Agent skills
+
+### Issue tracker
+
+Local markdown under `.scratch/<feature>/`. No remote tracker; PRs are not a triage surface. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Five canonical roles, default strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`), recorded as a `Status:` line in each issue file. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root (created lazily). See `docs/agents/domain.md`.
blob - /dev/null
blob + 1f1f3da5df55ec73c2be616a13d5fcfe56e42e40 (mode 644)
--- /dev/null
+++ LICENSE
+Copyright (c) 2026 Isaac <isaac@itm.works>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+----------------------------------------------------------------------
+
+src/common/log.c and src/common/log.h are reused from OpenBSD base and
+are covered by the same ISC terms above, copyright (c) 2003, 2004
+Henning Brauer <henning@openbsd.org>.
+
+fugu is inspired by the behavior of "pi" (the @earendil-works/pi-coding-agent
+project, MIT, copyright (c) Mario Zechner). It is a clean-room reimplementation
+and shares no source with pi.
blob - /dev/null
blob + 7ac86ed774c4324a94572ea3a4703c4bdd0aa43b (mode 644)
--- /dev/null
+++ Makefile
+# fugu — OpenBSD-native AI coding agent
+
+# Keep VERSION in step with V in the port's Makefile.
+VERSION= 0.1
+DISTNAME= fugu-${VERSION}
+# Where `make dist` writes the release tarball the port installs from.
+DISTDIR?= /usr/ports/distfiles
+
+SUBDIR= src
+
+.include <bsd.subdir.mk>
+
+# Package the working tree into the source distfile the port consumes: a clean
+# ${DISTNAME}.tar.gz (top directory ${DISTNAME}/, no build artifacts) written to
+# ${DISTDIR}. Afterwards, in the port: `make makesum` to refresh distinfo, then
+# `make reinstall`. The model never installs itself; this only produces the
+# tarball that ports(7) builds and installs from.
+.PHONY: dist
+dist:
+ rm -rf ${DISTNAME}
+ mkdir ${DISTNAME}
+ pax -rw LICENSE Makefile README.md docs etc man regress run-fugu src \
+ ${DISTNAME}
+ find ${DISTNAME} \( -name '*.o' -o -name '*.d' \) -exec rm -f {} +
+ find ${DISTNAME} -type d -name obj -exec rm -rf {} +
+ find ${DISTNAME}/regress -type f -name '*_test' -exec rm -f {} +
+ rm -f ${DISTNAME}/src/parent/parse.c ${DISTNAME}/src/parent/parse.h \
+ ${DISTNAME}/regress/parse/parse.c ${DISTNAME}/regress/parse/parse.h
+ rm -f ${DISTNAME}/src/parent/fugu ${DISTNAME}/src/ui/fugu-ui \
+ ${DISTNAME}/src/net/fugu-net ${DISTNAME}/src/exec/fugu-exec \
+ ${DISTNAME}/src/fetch/fugu-fetch
+ mkdir -p ${DISTDIR}
+ tar -czf ${DISTDIR}/${DISTNAME}.tar.gz ${DISTNAME}
+ rm -rf ${DISTNAME}
+ @echo "wrote ${DISTDIR}/${DISTNAME}.tar.gz"
blob - /dev/null
blob + 40d2e5ddc746cab904cec4884e9462119c1e0671 (mode 644)
--- /dev/null
+++ README.md
+# fugu
+
+`fugu` is an OpenBSD-native AI coding agent — a small, privilege-separated
+coding assistant written in C, in OpenBSD style, sandboxed with `pledge(2)` and
+`unveil(2)`. It is a clean-room reimplementation (in spirit, not source) of the
+TypeScript agent *pi* by Mario Zechner.
+
+## Status
+
+Functional. The five privilege-separated processes, the curses and line-mode
+front ends, the streaming Anthropic agent loop, the file/shell/search and
+brokered web tools, and the append-only session journal with resume, `/clear`
+and `/compact` are all in place, with man pages and an OpenBSD port. The
+behavior is exercised by the `regress/` suite (including `make`-driven
+`pledge`/`unveil` sandbox checks, verifiable at the syscall level with
+`ktrace`/`kdump`).
+
+## Usage
+
+ fugu # start a session in the current project
+ fugu -l # list saved sessions
+ fugu -c # resume the most recent session
+ fugu -r <id> # resume a session by id
+ fugu -n # print the effective config (secrets redacted) and exit
+
+In a session, type a message and press Enter; `/clear` resets the active
+context, `/compact` replaces it with a model-written summary, and `/quit`
+exits. Configuration and the Anthropic key live in `~/.fugu/fugu.conf` (mode
+`0600`); see the `fugu(1)` and `fugu.conf(5)` manuals.
+
+## Platform
+
+**OpenBSD only.** fugu depends on `pledge(2)`, `unveil(2)`, `libtls`, `imsg(3)`,
+base `curses`, and the BSD `make` build system. It deliberately does **not**
+build on Linux or other systems — portability shims would undermine the security
+model.
+
+## Architecture
+
+Five privilege-separated processes, each a separate binary that the parent
+`fork`+`exec`s, communicating over `imsg(3)` socketpairs. The parent relays all
+worker-to-worker messages (no direct worker-to-worker channels):
+
+| binary | role | pledge |
+|--------|------|--------|
+| `fugu` | parent: coordinator + imsg relay; reads config, delegates secrets | `stdio proc` |
+| `fugu-ui` | curses/line TUI; owns the tty and the session journal | `stdio tty` |
+| `fugu-net` | libtls HTTPS to the Anthropic API; holds the API key | `stdio inet dns` |
+| `fugu-exec` | file tools + `ksh`; no network | `stdio rpath wpath cpath proc exec` |
+| `fugu-fetch` | libtls web search/fetch (Kagi); parses untrusted HTML in isolation | `stdio inet dns` |
+
+`fugu-exec`'s execpromises omit `inet`, so neither it nor the commands it runs
+can reach the network (opt in per project with `allow_subprocess_net`); its
+`unveil` confines writes to the working tree. `fugu-ui` opens its journal under
+`~/.fugu/sessions` while it briefly holds `rpath wpath cpath`, then narrows to
+`stdio tty`.
+
+Each role links only the libraries it needs (`libtls` only in net/fetch,
+`curses` only in ui), so the untrusted-input processes never map code they don't
+use. fugu runs entirely unprivileged (no root, not a daemon).
+
+## Build (on OpenBSD)
+
+ make obj # optional
+ make
+ doas make install # installs under ${PREFIX:-/usr/local}
+
+The OpenBSD port lives in the ports tree (not this repo), under
+`mystuff/productivity/fugu`; build a package with `make package` there.
+
+## Layout
+
+ src/common/ shared code: log, util, buf, json, imsgproto, http, sse, conf
+ src/parent/ fugu -> ${PREFIX}/bin (also installs the man pages)
+ src/ui/ fugu-ui -> ${PREFIX}/libexec/fugu
+ src/net/ fugu-net -> ${PREFIX}/libexec/fugu
+ src/exec/ fugu-exec -> ${PREFIX}/libexec/fugu
+ src/fetch/ fugu-fetch -> ${PREFIX}/libexec/fugu
+ man/ fugu.1, fugu.conf.5
+ etc/ fugu.conf.sample
+ regress/ unit, integration, and pledge/unveil sandbox tests
+
+## License
+
+ISC — see [LICENSE](LICENSE). Bundles the shared OpenBSD `log.c`/`log.h` (ISC,
+© Henning Brauer). Inspired by *pi* (MIT, © Mario Zechner); no shared source.
blob - /dev/null
blob + c5d32ac7e2d236fda6ec003c3df94fa4f0b540b7 (mode 644)
--- /dev/null
+++ docs/adr/0001-keep-kagi-as-search-adapter-seam.md
+# 1. Keep kagi as a single-adapter seam in anticipation of multi-provider search
+
+- Status: Accepted
+- Date: 2026-06-25
+
+## Context
+
+fugu has one search adapter today: `src/fetch/kagi.{c,h}`. Its interface is the
+pure request-path/response-shape half of the Kagi v1 search API —
+`kagi_search_body()` writes the JSON request body, `kagi_format_results()`
+renders a success envelope into the text the model sees, and `kagi_error_msg()`
+extracts the message from an error envelope. The HTTP transport, the
+Authorization header, and the secret token live in `fetch_run.c`; the adapter
+never touches a socket or a file.
+
+This is a one-implementation interface. A deletion test asks: if `kagi.{c,h}`
+were inlined into `do_web_search()` in `fetch_run.c`, what would get worse? The
+answer is that the JSON-shape logic (envelope discrimination, per-field caps,
+malformed-response handling) would concentrate in the same function that
+already owns transport, deadlines, the SSRF guard, and the Kagi secret. That
+function is already the longest in the fetch worker; folding the adapter in
+would deepen it further and entangle two concerns — wire protocol vs. response
+semantics — that today have a clean seam between them.
+
+The provider layer in `src/common/` is the precedent. `anthropic.{c,h}` and
+`openai.{c,h}` share `provider.h` and the same shape: a pure request builder
+plus a streaming decoder that fires a common `struct llm_cbs`. The net worker
+is identical for either provider. That shape was introduced before the second
+adapter existed; it paid off when openai landed as a drop-in alongside
+anthropic rather than a refactor of the net worker.
+
+The kagi seam exists for the same reason. We have confirmed there is a future
+with other search adapters — Perplexity, Exa, and similar — and the seam is
+the insertion point for them.
+
+## Decision
+
+Keep `src/fetch/kagi.{c,h}` as a search adapter. Its interface stays the pure
+request-path/response-shape pair: build a request body, render a success
+envelope, extract an error message. Transport, secrets, and ACL stay in
+`fetch_run.c`.
+
+Future search providers (Perplexity, Exa, etc.) plug in at the same seam — a
+sibling adapter module exposing the same shape, selected by configuration in
+the fetch worker. When the second adapter lands, the common shape should be
+lifted into a `search_provider.h` mirroring `provider.h`, the same way
+`provider.h` was introduced alongside the second LLM adapter.
+
+## Consequences
+
+- Cost: with only one implementation behind it today, this is a hypothetical
+ seam — leverage we have paid for but not yet collected. The adapter adds one
+ module, one header, and a small amount of indirection that a strict YAGNI
+ reading would call premature.
+- Benefit: a second search provider becomes a drop-in adapter rather than a
+ refactor of `fetch_run.c`. The transport, the SSRF guard, the deadline
+ machinery, and the secret-handling in `do_web_search()` do not have to be
+ re-opened. The pattern is the same one that made openai a drop-in alongside
+ anthropic.
+- Locality: response-shape logic stays out of `fetch_run.c`, which keeps the
+ fetch worker focused on transport, sandboxing, and ACLs. The adapter is pure
+ (no sockets, no files), so it is trivially testable and trivially auditable
+ for a privilege-separated worker.
+- Guidance for reviewers: do not re-suggest collapsing this seam on the basis
+ that there is only one implementation behind it. The seam is intentional,
+ matches the established provider-adapter precedent, and is the documented
+ insertion point for additional search providers. If a second adapter has
+ still not landed after a long period, revisit this ADR rather than silently
+ inlining.
blob - /dev/null
blob + 457df00d424b7a11e6d3e0e00893ff35298c4583 (mode 644)
--- /dev/null
+++ docs/adr/0002-keep-html2text-as-common-module.md
+# 2. Keep html2text as a common module, anticipating multi-worker consumption
+
+Date: 2026-06-25
+
+## Status
+
+Accepted
+
+## Context
+
+`html2text` (`src/common/html2text.{c,h}`) is a single-pass, allocation-light HTML stripper: ~343 LOC of carefully tested implementation hidden behind a one-function interface:
+
+```c
+void html2text(const char *html, size_t len, struct buf *out);
+```
+
+The implementation handles hostile web input in strictly linear time, defers whitespace to collapse runs and trailing space, skips `script`/`style`/comment content, decodes the common character references, breaks at block-level elements, and caps the produced text at `HTML2TEXT_MAXOUT` (256 KiB).
+
+Today the only consumer is `fugu-fetch` (`src/fetch/fetch_run.c:383`), which calls it when the fetched `Content-Type` is HTML or unknown. `fugu-fetch` and `fugu-net` both run with exactly `stdio inet dns`; `html2text` itself needs no syscalls beyond `stdio`, so its sandbox cost is zero in any worker that already has `stdio`. Tests live in `regress/html2text/` and exercise both exact-output and substring contracts.
+
+The owner has stated that html2text will probably be used in other processes. A plausible second consumer already lurks: `net_run.c`'s tool description advertises the fetch tool as returning "readable text (HTML converted to plain text)" — the same reduction will be useful anywhere a worker handles an HTML payload it did not request (model-server error pages, provider HTML responses to non-streaming endpoints, future tool outputs that proxy web content).
+
+Without an ADR, a future reviewer applying YAGNI/locality heuristics is likely to suggest folding the 343 LOC implementation back into `fetch_run.c` on the grounds that it has one caller. This ADR exists to head that off.
+
+## Decision
+
+Keep `html2text.{c,h}` in `src/common/` as a shared deep module. The interface stays a single function with a fixed output cap; the implementation stays opaque to callers.
+
+Specifically:
+
+- Do not inline `html2text` into `fetch_run.c`, even though fetch is currently its sole consumer.
+- Do not split `html2text` across workers; there is one canonical implementation, and the `regress/html2text` suite is the authority on its behavior.
+- New consumers in other workers (`net`, `exec`, `ui`, `parent`) link the same translation unit via their `Makefile`'s `SRCS`, exactly as `fetch` does today.
+- The seam — the one-function header — is the contract. Callers may not depend on intermediate state, the `emit` struct, or the entity table.
+
+## Consequences
+
+**Positive**
+
+- Future workers that need HTML stripping inherit the safe-stripping invariants (linear time, output cap, no allocation amplification, defended against malformed input) for free, without re-deriving them.
+- The shape mirrors `mdrender`, the other common deep module with currently one consumer (`ui`) — establishing a consistent pattern for "small interface, deep implementation, shared across workers."
+- `regress/html2text` tests cover all current and future consumers; no per-worker duplication of test fixtures.
+- Code review is anchored: this ADR exists specifically so reviewers do not re-propose collapsing `html2text` into `fetch_run.c` on locality grounds.
+
+**Negative / accepted costs**
+
+- The shared seam is currently hypothetical. Until a second consumer lands, the module pays a small organizational cost (a header in `common/`, an entry in `regress/`) for benefit not yet realized.
+- A change to the `html2text` contract (e.g. raising `HTML2TEXT_MAXOUT` or changing whitespace policy) will eventually need coordination across multiple workers rather than one file.
+
+**Revisit if**
+
+- 18 months pass with `fetch` still the only consumer, or
+- a second consumer's needs diverge enough (different output format, different cap policy) that a shared interface becomes a forcing function rather than a convenience.
blob - /dev/null
blob + b548c538d606a65bde9bcd4ca3a458ec7c52cd9b (mode 644)
--- /dev/null
+++ docs/agents/domain.md
+# Domain Docs
+
+How the engineering skills should consume this repo's domain documentation when exploring the codebase.
+
+## Before exploring, read these
+
+- **`CONTEXT.md`** at the repo root, or
+- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
+- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
+
+If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
+
+## File structure
+
+Single-context repo (most repos):
+
+```
+/
+├── CONTEXT.md
+├── docs/adr/
+│ ├── 0001-event-sourced-orders.md
+│ └── 0002-postgres-for-write-model.md
+└── src/
+```
+
+Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
+
+```
+/
+├── CONTEXT-MAP.md
+├── docs/adr/ ← system-wide decisions
+└── src/
+ ├── ordering/
+ │ ├── CONTEXT.md
+ │ └── docs/adr/ ← context-specific decisions
+ └── billing/
+ ├── CONTEXT.md
+ └── docs/adr/
+```
+
+## Use the glossary's vocabulary
+
+When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
+
+If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
+
+## Flag ADR conflicts
+
+If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
+
+> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
blob - /dev/null
blob + a2f08fb077b319ddaffe5ca74b93a4930cdc5248 (mode 644)
--- /dev/null
+++ docs/agents/issue-tracker.md
+# Issue tracker: Local Markdown
+
+Issues and PRDs for this repo live as markdown files in `.scratch/`.
+
+## Conventions
+
+- One feature per directory: `.scratch/<feature-slug>/`
+- The PRD is `.scratch/<feature-slug>/PRD.md`
+- Implementation issues are `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`
+- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
+- Comments and conversation history append to the bottom of the file under a `## Comments` heading
+
+## When a skill says "publish to the issue tracker"
+
+Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
+
+## When a skill says "fetch the relevant ticket"
+
+Read the file at the referenced path. The user will normally pass the path or the issue number directly.
blob - /dev/null
blob + b716855d485f3865f9dfde2a82141721f065b2e7 (mode 644)
--- /dev/null
+++ docs/agents/triage-labels.md
+# Triage Labels
+
+The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
+
+| Label in mattpocock/skills | Label in our tracker | Meaning |
+| -------------------------- | -------------------- | ---------------------------------------- |
+| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
+| `needs-info` | `needs-info` | Waiting on reporter for more information |
+| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
+| `ready-for-human` | `ready-for-human` | Requires human implementation |
+| `wontfix` | `wontfix` | Will not be actioned |
+
+When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
+
+Edit the right-hand column to match whatever vocabulary you actually use.
blob - /dev/null
blob + f46a47bb428394fbd2b3c4f08c7236e68d53647a (mode 644)
--- /dev/null
+++ docs/base-idiom-audit-findings.md
+# fugu ↔ OpenBSD base: idiom audit findings
+
+**Date:** 2026-06-21 · **Scope:** read-only audit (no code changed) · **Host:** OpenBSD 7.9-current, amd64 (`server`), base at `/usr/src`.
+
+This is a complete, function-by-function accounting of where fugu's hand-rolled
+utilities overlap something OpenBSD **base** already does well — a "seam." Each of
+the **233** functions/groups across **21 source units** is classified exactly once:
+
+- **ADOPT** — base exposes a *linkable* idiomatic equivalent (libc / libutil / libevent / libtls / a base header) that is a drop-in or near-drop-in.
+- **REFERENCE** — base solves the same problem but only *inside a base program* (not linkable, e.g. `ftp(1)`'s HTTP client). Align idioms/edge-cases; don't link.
+- **KEEP** — no base equivalent (or only a ports lib, out of charter). One-phrase reason recorded.
+
+**Method / confidence.** Every ADOPT/REFERENCE claim was produced by a per-file
+audit agent and then independently re-checked by an adversarial verifier against
+the live `/usr/src` tree, headers, and man pages — 42 agents total. **16 citation
+corrections** were applied (all kept the verdict; only the file/line was wrong);
+they appear inline as **[verified correction: …]** in the Notes column. No verdict
+was overturned and no missed seams were found. The `imsg` API was confirmed to be
+the **post-rewrite `imsgbuf_*` / `imsg_get_ibuf`** surface — **fugu is already on
+it, no migration debt.**
+
+---
+
+## TL;DR
+
+| Bucket | Count | Meaning |
+|---|---|---|
+| **ADOPT — actionable** | **1** | Genuine new library adoption (`scandir`). |
+| **ADOPT — already idiomatic** | 16 | Already correctly on the base API; listed to prevent regression. |
+| **REFERENCE** | 67 | Align to a base program's idiom; a handful are real work, most confirm-only. |
+| **KEEP** | 149 | No base option; justified bespoke code. |
+
+The headline is honest: fugu is **already strongly base-idiomatic**. Only **one**
+clean new library adoption exists; the real value is in a few **REFERENCE/SYNC**
+alignments (security-relevant secrecy check, lexer modernization, logging drift).
+
+---
+
+## 1. Adopt these (clear wins)
+
+### Actionable (currently NOT on base — change recommended)
+
+- **`parent/parent.c` `resolve_last` / `list_sessions` → `scandir(3)` + comparator.**
+ Replace the hand-rolled `opendir`/`while(readdir)`/`closedir` trio with one
+ `scandir()` call using a `select()` filter matching the `.jsonl` suffix. **Keep**
+ the mtime `qsort`/`sessrow_cmp` — `scandir`'s `alphasort` sorts by *name*, and
+ fugu orders by *mtime*. Cite `/usr/src/usr.bin/diff/diffdir.c:82`, man `scandir(3)`
+ (decl `/usr/include/dirent.h:91`). **Effort M.** The single clean new adoption in the tree.
+
+### Already idiomatic (confirmed on the base API — do not regress)
+
+These are already calling the right linkable base symbol; recorded for completeness:
+
+- `conf.c` list lifecycle → `sys/queue.h` TAILQ macros.
+- `tlsconn.c` (all 7 fns) → libtls `tls_*` (`-ltls`).
+- `net_run.c` `net_apply_config`/`cfg_str` → `strtonum(3)`; `net_state_clear` → `freezero(3)` + `tls_config_free`.
+- `exec.c` `main` → `getopt(3)`/`signal(3)`/`fstat(2)`; `tools_file.c` `tool_edit` → `memmem(3)`.
+- `ui_curses.c` `cw`→`wcwidth(3)`, `utf8_to_wcs`→`mbrtowc(3)`, `submit`→`wcrtomb(3)`, `do_resize`→`resizeterm(3)`.
+- `ui.c`/`journal.c` line input → `getline(3)`; `exec_run.c` sandbox → two-arg `pledge`/`unveil` `execpromises`; `imsgproto.c` → current `imsgbuf_*`.
+
+---
+
+## 2. Reference / Sync (align to base source — the real alignment work)
+
+**Priority 1 — `conf.c` `conf_check_secrecy` → byte-sync to base `check_file_secrecy()`, and add it to `parse.y` `pushfile`.** *(Security-relevant.)*
+fugu stores API keys (`anthropic_key_file`, `kagi_token_file`) yet `pushfile`
+performs **no** secrecy check. Base copies `check_file_secrecy()` verbatim into ~30
+daemons (`/usr/src/usr.sbin/httpd/parse.y:1795`, called from `pushfile` at `:1834`;
+`bgpd/parse.y:4007`). Two deltas to resolve deliberately: **(a)** base takes an
+**open fd + `fstat()`** (closes the TOCTOU window) — fugu re-`stat()`s by path;
+adopt the fd/`fstat` shape. **(b)** fugu's policy is *stricter* (exact owner, true
+0600) than base (owner-or-root, allows group/world *read*) — **keep fugu's stricter
+policy**, but make the code read like the base idiom reviewers recognize. It is a
+program-internal static (not in `lib/` — confirmed), so this is REFERENCE (copy the
+~18 lines), never ADOPT. **Effort S–M.**
+
+**Priority 2 — `common/parse.y` lexer → sync to current `usr.sbin/httpd/parse.y`.**
+fugu's lexer is forked from an *older* pf/httpd template and has drifted on three
+coupled points:
+1. No `igetc()` indirection layer (`httpd/parse.y:1540`) — fugu inlines raw getc/unget in `lgetc`.
+2. Fixed `pushback_buffer[128]` (returns EOF on overflow) vs httpd's **growable per-file `ungetbuf`** moved into `struct file`, doubled via `reallocarray` (`:1605-1616`) — removes the 128-char ceiling.
+3. Old `parsebuf`/`parseindex` macro expansion vs httpd's **`START_EXPAND`/`DONE_EXPAND` sentinels + `expanding` flag** pushed back through `igetc` (`:1656,1679-1684`) — enables nested macros and deletes the separate parsebuf path.
+
+**Correction to the brief's lead:** there is **no `expand_macros` function** in current
+base `parse.y` (verified: 0 hits in any `usr.sbin`/`sbin` parse.y) — the real
+mechanism is the sentinel/flag scheme above. This is the highest-effort item
+(removing `parsebuf` touches `lgetc`+`lungetc`+`findeol`+`yylex` together) and
+couples with Priority 1 (threading the `secret` flag through `pushfile`/`conf_parse_file`).
+**Effort M–L;** low correctness risk — modernization plus the secrecy win.
+
+**Priority 3 — `common/log.c` → re-baseline onto current base `log.c`.**
+fugu's file is a verbatim copy of `bgpd/log.c` rev 1.64. Current `ntpd/log.c` rev
+1.19 has evolved: a `LOG_TO_STDERR|LOG_TO_SYSLOG` **dest bitmask** (dual-sink instead
+of strictly either/or), multi-level `-v` verbosity, and dropped the `"fatal in "`
+prefix. Decide whether to adopt the ntpd lineage. The `log_warn`/`log_warnx`/`fatal`/
+`fatalx` leaves map to **linkable** libc `warn(3)`/`warnx(3)`/`err(3)`/`errx(3)`
+(in `lib/libc/gen/{warn,warnx,err,errx}.c`) — but **keep the wrappers**: the libc
+variants are stderr-only and cannot route to syslog or honor `log_procname`.
+**Effort S** (cherry-pick drift) to **M** (full re-baseline).
+
+**Priority 4 — low-effort idiom alignment / confirm-only (no behavior change):**
+
+- `util.c` x-allocators → ssh `xmalloc.c`: add the `SIZE_MAX/nmemb < size` guard in `xcalloc` to match canonical; consider `xrecallocarray`/`freezero` wrappers for secret buffers (charter). `xasprintf` is **not** present — add the ssh idiom if formatted-alloc is needed.
+- `http.c` → ftp `fetch.c`: bracket IPv6-literal hosts + drop default `:80/:443` from the Host header (`fetch.c:740-772`); trim trailing OWS (SP/HT) on header lines (`:880-882`). Chunked-decode + `strtonum` already match.
+- `url.c` → ftp `fetch.c`: keep fugu's **stricter** allowlist/anti-injection (better than base's blocklist); align only IPv6/port edge cases (`fetch.c:497-514`).
+- `confload.c` → ssh `groupaccess.c:47` (`ga_init`) for the group-name loop; `getgrouplist(3)` already correct. Decide grow-and-retry vs fixed `NGROUPS_MAX`.
+- `imsgproto.c` / `net_run` imsg paths → already current `imsgbuf_*`; mirror `relayd/proc.c:593 proc_dispatch` EPIPE/channel-death handling.
+- `tools_file.c` `write_atomic` → acme-client `fileproc.c:59` — **fugu adds `fsync(2)`, keep it**; `write_all` → ssh `atomicio.c:50` / vmd `atomicio.c:56`, align EAGAIN/0-byte handling if the fd ever goes non-blocking.
+- `tools_shell.c` `run_capture` → ssh `misc.c:2786 subprocess()` — fugu adds timeout + output cap + SIGKILL (keep); align EINTR. **Do not** adopt `popen` (no stderr/timeout).
+- `fetch_run.c` `dial`/`http_fetch` → ftp `fetch.c:599 timed_connect` / `:646` TLS loop — already aligned; keep the per-`ai` `addr_blocked` SSRF guard ftp lacks.
+- `ui_curses.c` → mandoc `term_ascii.c:93` (locale), ssh `utf8.c`, less `line.c` — already idiomatic; cross-check the `ascii_rep`/`fold_wcs` table against mandoc's; handle `wcwidth==0` combining chars in `render` as less does.
+- `json.c` `json_emit_escaped`/`json_tok_strdup` → `vis(3)`/`unvis(3)` are adjacent but **not** drop-ins (different escape grammar) — keep fugu's JSON-correct tables.
+
+---
+
+## 3. Evaluate (tradeoff calls — you decide; recommendation given)
+
+**`buf.c` → `ibuf` (libutil) / `open_memstream(3)` — RECOMMEND KEEP.**
+Neither is a clean drop-in. `open_memstream` (string role; base user
+`radiusd_standard.c:345`) loses live O(1) `.data`/`.len` introspection (updates only
+on `fflush`) and fatal-OOM. `ibuf` (wire-byte role; `imsg-buffer.c`) has **no
+formatted-append** — yet `buf_printf` is the dominant use across json/http/url — and
+is shaped for framing, not NUL-terminated strings. `buf` is ~115 lines,
+dependency-free, fatal-on-OOM, with a hard-cap growth guard the base options lack.
+Adopting either is **effort L for negative payoff**; align idioms, keep `buf`.
+
+**`parent.c` `relay_loop` poll(2) → libevent — RECOMMEND KEEP poll() for now.**
+Every base privsep daemon uses libevent (`dhcpleased/frontend.c:167-190`), and
+libevent **is** base (charter-allowed). But fugu is deliberately **foreground,
+per-invocation, non-daemon**, fixed 4-channel, no timers/signals beyond SIGWINCH/
+SIGPIPE — a flat `poll()` loop is simpler and callback-free. The one real gap:
+write-side backpressure (`imsgbuf_queuelen` → arm `EV_WRITE`); fugu only `POLLIN`s
+and relies on `ip_flush` blocking — addressable in the poll model too. Adopt libevent
+only if timers/backpressure/more channels arrive. **This is the single largest
+stylistic divergence from base — and a justified one.**
+
+**`parent.c` `run`/`spawn` → relayd/httpd `proc.c` privsep framework — RECOMMEND REFERENCE, don't link.**
+`relayd/proc.c:180 proc_init` / `:70 proc_exec` / `:351 proc_kill` are the canonical
+privsep helpers, but pull in libevent + the full `struct privsep`/instance-array
+model fugu intentionally avoids. fugu's flat 4-worker spawn (with
+`closefrom(IMSG_CHILD_FD+1)` + `/dev/null` redirection) is arguably *tighter* than
+`proc_exec`. Mirror the ordering/teardown discipline; keep fugu's implementation.
+
+---
+
+## 4. Keep (justified — no base option)
+
+- **No base library exists:** `json.c` (no base JSON emitter/parser), `sse.c` (no base SSE/EventSource), `html2text.c` (no base HTML parser — lynx/w3m are ports), `anthropic.c` (Anthropic Messages protocol), `kagi.c` (Kagi API), `convo.c` (Messages-API conversation model), `journal.c` (append-only jsonl).
+- **Security cores with no base analog:** `fetch_run.c` `addr_blocked` (SSRF private-range classifier — base has none), `imsgproto.c` reassembly/oldest-first eviction (base imsg has no chunking), `conf.c` merge engine (sshd-Match-style, program-internal), path-traversal guards (`session_exists`, `valid_id`).
+- **App glue over fugu's own buf/json/imsg layers:** the bulk of `net_run.c`, `exec_run.c`, `fetch_run.c`, `ui.c` — application control flow with no library to adopt.
+
+---
+
+## 4b. OpenBSD base-library linkage check (follow-up)
+
+A library-first pass (complementing the file-by-file audit above): what each
+binary actually links, whether it is all base, and whether any *unused* base
+library would replace hand-rolled code. Verified on `server` via `objdump -p`
+(direct `DT_NEEDED`), `ldd`, and `/usr/lib`.
+
+**Linkage is correct, minimal, and 100% base.** Direct `NEEDED` per binary:
+
+| Binary | Direct NEEDED (base libs) |
+|---|---|
+| `fugu` (parent) | libc, libutil |
+| `fugu-ui` | libc, libcurses, libutil |
+| `fugu-net` | libc, libtls, libutil |
+| `fugu-exec` | libc, libutil |
+| `fugu-fetch` | libc, libtls, libutil |
+
+`libutil` (imsg) is shared by all; `libtls` only where TLS is spoken; `libcurses`
+only in the UI. `libssl`/`libcrypto` appear in `ldd` but are **transitive** via
+`libtls` (not direct `NEEDED`), so the port's `WANTLIB = c curses tls util` is
+exactly right. **No `/usr/local` (ports) dependency anywhere** — charter satisfied.
+
+**No new library to adopt.** Every base library fugu does *not* link was checked
+and is either not-a-fit or a feature-add, not a replacement for hand-rolled code:
+
+| Base lib (present) | Verdict | Reason |
+|---|---|---|
+| `libedit` (`editline(3)`) | KEEP hand-rolled | **Correction: libedit IS in base** (the audit's `handle_input` row wrongly said otherwise). Still not a fit: `el_wgets` owns a blocking read loop and can't interleave with the curses `nodelay`+imsg `poll(2)` loop that edits input *while* streaming deltas render. Line-mode (`getline`) is non-interactive, so editing buys nothing there either. |
+| `libevent` | EVALUATE → KEEP | The poll→libevent design question (see §3); base, deliberately unused for a foreground non-daemon. |
+| `libpanel`/`libform`/`libmenu` | KEEP | Curses extensions for overlapping windows/forms/menus; fugu's TUI is one full-screen layout — no fit. |
+| `libz` | KEEP (note) | gzip/deflate. fugu sends no `Accept-Encoding` and never receives compressed bodies, so nothing to decode today. Available **if** HTTP gzip is ever wanted (feature, not a replacement). |
+| `libm` / `libexpat` / `libreadline` / `libcrypto` (direct) | KEEP | No floating point; JSON not XML (jsmn); libedit is preferred over GPL readline and still no fit; no direct crypto (TLS is libtls's job). |
+
+Net: linkage needs no change; the only artifact is the libedit fact-fix to the
+`handle_input` row below.
+
+---
+
+## 5. Complete per-function accounting
+
+Legend: **Eff** = adoption effort (S/M/L). `path:line` are real sites confirmed on
+`server`. **[verified correction: …]** marks a citation the adversarial verifier
+fixed. Every function/group across all 21 units appears exactly once below.
+
+### `src/common/log.c`
+
+_Verbatim hand-copy of /usr/src/usr.sbin/bgpd/log.c rev 1.64 (only the $OpenBSD$ CVS tag line removed); the canonical daemon-logging idiom is not a linkable lib, so the whole unit is KEEP/SYNC-to-base, with the warn/fatal leaf group REFERENCE-aligned to libc err(3)/warn(3)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| log_init | Sets debug/verbose state, calls log_procinit(__progname), openlog() when not in debug, tzset(). | `/usr/src/usr.sbin/bgpd/log.c:34` | **KEEP** | S | Hand-copied base code; sync seam, not a libc adopt. | fugu line 32 == bgpd line 34 (offset 2 from the dropped $OpenBSD$ tag). NOTE DRIFT vs newer ntpd log.c rev 1.19: log_init(int n_dest,int n_verbose,int facility) uses a LOG_TO_STDERR\|LOG_TO_SYSLOG bitmask instead of a boolean debug. fugu is on the older bgpd lineage. |
+| log_procinit | Stashes the process name used in fatal() messages. | `/usr/src/usr.sbin/bgpd/log.c:49` | **KEEP** | S | Identical to bgpd; sync seam. | ntpd rev 1.19 makes log_procname a non-static extern; cosmetic only, no functional difference. |
+| log_setverbose | Setter for the verbose level (toggles log_debug output). | `/usr/src/usr.sbin/bgpd/log.c:56` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift from either bgpd or ntpd. |
+| log_getverbose | Getter for the verbose level. | `/usr/src/usr.sbin/bgpd/log.c:62` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift from either bgpd or ntpd. |
+| logit | Varargs front-end: collects ap and forwards to vlog at a given priority. | `/usr/src/usr.sbin/bgpd/log.c:68` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift; pure va_start/vlog/va_end shim. |
+| vlog | Core sink: in debug mode vfprintf to stderr (asprintf adds newline, OOM fallback); else vsyslog. Preserves errno. | `/usr/src/usr.sbin/bgpd/log.c:78` | **KEEP** | M | Sync seam; the dual-sink routing logic has no linkable base equivalent. | DRIFT vs ntpd log.c rev 1.19 vlog: ntpd va_copy's ap and can emit to BOTH stderr and syslog (dest bitmask); fugu's bgpd-era version is strictly either/or. Adopt the ntpd variant if dual-sink logging is ever desired. |
+| log_warn | errno-appending warning: asprintf "%s: %s"(emsg,strerror(errno)) then vlog(LOG_ERR); OOM fallback; restores errno. | `/usr/src/lib/libc/gen/warn.c` warn(3) | **REFERENCE** | M | warn(3)/vwarnc(3) IS linkable but writes only to stderr via __progname; cannot route to syslog or honor log verbosity. | **[verified correction: warn() is in warn.c, not err.c]** err.c lives under lib/libc (linkable, confirmed err.h decls /usr/include/err.h:55-65). Already mirrors the strerror-append + errno-preservation idiom; keep the wrapper purely for the syslog sink. Cannot drop-in replace. |
+| log_warnx | Warning without errno suffix; vlog(LOG_ERR). | `/usr/src/lib/libc/gen/warnx.c` warnx(3) | **REFERENCE** | S | warnx(3) is the linkable libc analog but stderr-only; keep wrapper for the syslog sink. | **[verified correction: warnx() is in warnx.c, not err.c]** Decl confirmed at /usr/include/err.h:63. Align idiom only; the LOG_ERR/syslog routing is the reason to keep the wrapper. |
+| log_info | vlog(LOG_INFO) informational message. | `/usr/src/usr.sbin/bgpd/log.c:138` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No libc equivalent at LOG_INFO severity (err/warn only cover error/warning); no drift from canonical. |
+| log_debug | vlog(LOG_DEBUG) gated on verbose flag. | `/usr/src/usr.sbin/bgpd/log.c:148` | **KEEP** | S | Sync seam. | Minor DRIFT vs ntpd rev 1.19 which gates on verbose>1 (multi-level verbosity); fugu/bgpd gate on verbose!=0. Only matters if multi-level -v is wanted. |
+| vfatalc | Static helper: formats 'fatal in <proc>: <msg>: <strerror(code)>' (or without code) and logit(LOG_CRIT). | `/usr/src/usr.sbin/bgpd/log.c:160` err(3) | **KEEP** | M | Sync seam; the LOG_CRIT/syslog formatter has no linkable base equivalent. | DRIFT vs ntpd rev 1.19: ntpd drops the 'fatal in ' prefix, logging just '<proc>: ...'. fugu retains bgpd's 'fatal in ' wording. static, so REFERENCE-only mapping to verrc(3); kept as part of the synced unit. |
+| fatal | Varargs entry: vfatalc(errno,...) then exit(1). __dead. | `/usr/src/lib/libc/gen/err.c` err(3) | **REFERENCE** | S | err(3)/verrc(3) is the linkable 'print strerror + exit' primitive (__dead, errno-append) but exits via stderr only and can't hit syslog/log_procname. | **[verified correction: err() lives in err.c (decl err.h:43)]** err.h:43 declares err as __dead, matching fugu's __dead fatal in log.h. Align idiom; keep wrapper for the syslog sink. exit(1) vs err()'s configurable eval is the only semantic gap. |
+| fatalx | Varargs entry: vfatalc(0,...) then exit(1) (no errno). __dead. | `/usr/src/lib/libc/gen/errx.c:34` errx(3) | **REFERENCE** | S | errx(3) is the linkable libc analog (print + exit, no strerror) but stderr-only. | **[verified correction: errx() __dead void at errx.c:34]** Decl confirmed at /usr/include/err.h:51 (__dead). Align idiom; keep wrapper for syslog routing. |
+
+### `src/common/util.c`
+
+_All 4 functions are the ssh xmalloc.c "fatal-on-NULL" wrapper idiom — REFERENCE (xmalloc.c is program-internal, not a linkable lib); the underlying malloc/calloc/reallocarray/strdup are libc and already called directly inside. Wins are alignment to ssh's edge-case handling, not adoption of a library._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| xmalloc | malloc wrapper: rejects size==0, calls fatal() on NULL, never returns NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:27` malloc(3) | **REFERENCE** | S | Wrapper itself is not linkable from base (xmalloc.c lives in usr.bin/ssh/, not lib/); libc malloc already called inside. Align idiom to ssh: ssh's fatal message reports the byte count ('out of memory (allocating %zu bytes)'). fugu's body matches ssh closely otherwise. | Confirmed xmalloc.c exists only at /usr/src/usr.bin/ssh/xmalloc.c (find under /usr/src/lib returned nothing) => REFERENCE not ADOPT. malloc declared in <stdlib.h>, man 3 malloc. |
+| xcalloc | calloc wrapper: rejects nmemb/size==0, fatal() on NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:40` malloc(3) | **REFERENCE** | S | Not linkable; calloc already called inside. Alignment gap: ssh adds an explicit 'SIZE_MAX / nmemb < size' overflow check (xmalloc.c:47-48) before calloc; fugu omits it. calloc itself already detects overflow, so this is belt-and-suspenders, but mirror it to match canonical idiom. | calloc resolves to MALLOC(3), declared <stdlib.h>. Consider whether the redundant SIZE_MAX check is worth carrying given calloc already guards overflow. |
+| xreallocarray | reallocarray wrapper: rejects nmemb/size==0, fatal() on NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:56` malloc(3) | **REFERENCE** | S | Not linkable; reallocarray (the overflow-safe libc primitive, declared /usr/include/stdlib.h:263) already called inside. Idiom divergence: ssh does NOT reject zero size in xreallocarray (only in xmalloc/xcalloc); fugu adds a zero-size fatalx which is stricter/fine. Note base also offers recallocarray(3)/freezero(3) and ssh ships xrecallocarray (xmalloc.c:68) — adopt that wrapper if fugu ever needs zeroing reallocation for secrets per charter. | reallocarray at /usr/include/stdlib.h:263. recallocarray/freezero present in MALLOC(3) — relevant to 'secrets never leaked' charter; flag as a future addition, not a change to this func. |
+| xstrdup | strdup wrapper: fatal() on NULL, never returns NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:80` strdup(3) | **REFERENCE** | S | Not linkable; strdup (declared /usr/include/string.h:106) already called inside. fugu's strdup-based body is cleaner than ssh's strlen+xmalloc+memcpy version and equally correct — no change needed; keep the REFERENCE link only for the fatal-on-NULL contract. | strdup at /usr/include/string.h:106, man 3 strdup. Lead mentioned xasprintf but it is NOT present in this file (ssh provides xasprintf/xvasprintf at xmalloc.c:91-112 over libc vasprintf(3) — adopt the same idiom if/when fugu needs formatted alloc). |
+
+### `src/common/buf.c`
+
+_Tiny dependency-free string/byte buffer; base has two real equivalents (open_memstream(3) for the dominant string-building role, ibuf/libutil for wire bytes) but neither is a clean drop-in — open_memstream loses O(1) mid-build .data/.len introspection and fatal-OOM, ibuf has no printf — so the abstraction is a justified KEEP with REFERENCE alignment to open_memstream idioms._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| buf_printf | Formatted append via two-pass vsnprintf (size then write); trailing NUL written but not counted in len. | `/usr/src/usr.sbin/radiusd/radiusd_standard.c:345` open_memstream(3) | **REFERENCE** | M | open_memstream wraps a FILE* so vfprintf does the formatting natively and growth is automatic, but it is program-internal-style usage (a libc FILE), errors are silent (NULL/ferror) vs fugu's fatalx, and *psize only updates on fflush — so adopting it removes fugu's two-pass overflow-checked path but loses fatal-on-OOM. Mirror radiusd's open_memstream/fprintf/fclose idiom for string assembly; do not claim ibuf here — ibuf has no printf. | This is the dominant use of buf across json.c/http.c/url.c. ibuf (imsg.h) offers NO formatted-append primitive, so it is not an equivalent for buf_printf. |
+| buf_append | Raw byte append with grow; no-op on len==0. | `/usr/src/lib/libutil/imsg-buffer.c:137` ibuf_add(3) | **REFERENCE** | M | ibuf_add(struct ibuf*, const void*, size_t) in linkable libutil is a near-signature match and ibuf_dynamic(len,max) gives the growable/bounded variant fugu's sse.c maxtotal check wants. But ibuf returns int error codes (fugu treats OOM as fatal via xreallocarray) and is designed for wire framing, not NUL-terminated strings; aligning is worthwhile only if the buffer is reframed as bytes. Cite imsg.h:73 decl, imsg-buffer.c impl. | **[verified correction: ibuf_add def is :137 (verified)]** struct ibuf + full API declared /usr/include/imsg.h:34-115; ibuf_data/ibuf_size give .data/.len equivalents but only after-the-fact, not the live field access fugu uses. |
+| buf_reserve | Grow capacity to hold need more bytes; doubles from BUF_MIN(64) with explicit (size_t)-1/2 overflow guard -> fatalx. | `/usr/src/lib/libutil/imsg-buffer.c:76` ibuf_reserve(3) | **KEEP** | S | base ibuf_reserve returns a pointer to reserved space (write-cursor semantics), not fugu's capacity-only ensure; the doubling allocator itself is private to imsg-buffer.c. No linkable drop-in for this exact contract. | The overflow guard at buf.c:47 is the edge case to keep; imsg-buffer.c bounds growth via ibuf max arg instead — worth mirroring the bound idiom if ever reframed. |
+| buf_init | Zero-initialize the struct (NULL/0/0). | ibuf_dynamic(3) | **KEEP** | S | base buffers are heap-allocated by ibuf_dynamic returning struct ibuf*; fugu uses caller-owned stack structs with init — no base equivalent for in-place init of a caller struct. | Trivial; only exists because fugu embeds struct buf by value (json writer, sse parser) rather than owning a pointer. |
+| buf_putc | Append one byte (cast int->unsigned char). | putc(3) | **KEEP** | S | trivial one-line wrapper over buf_append; if open_memstream were adopted this becomes putc(c, fp). Not independently worth a base dependency. | Heavy use in json.c UTF-8 encoding (buf.c via json.c:336-348). Maps cleanly to putc only if the whole buffer becomes a FILE*. |
+| buf_puts | Append a C string via strlen+append. | fputs(3) | **KEEP** | S | trivial wrapper; equivalent to fputs(s, fp) under open_memstream. No standalone linkable equivalent for the struct-buf form. | Folds into the open_memstream REFERENCE decision; not an independent ADOPT. |
+| buf_reset | Logically truncate to empty (len=0) keeping capacity for reuse. | `/usr/src/lib/libutil/imsg-buffer.c:76` ibuf_truncate(3) | **KEEP** | S | ibuf_truncate exists (imsg.h:98) but operates on the wire-buffer write-position model, not fugu's capacity-retaining reset of a by-value struct; semantics differ enough that it is not a drop-in. | open_memstream has no cheap reset-keeping-capacity (would need rewind+ftruncate on the stream); this favors keeping fugu buf where reuse matters. |
+| buf_free | free data and re-zero the struct. | ibuf_free(3) | **KEEP** | S | ibuf_free frees a heap struct ibuf*; open_memstream requires fclose then free(*pbuf). fugu frees only the data of a caller-owned struct — no equivalent for that ownership shape. | Re-zeroing after free (buf.c:112-114) is a use-after-free hardening idiom worth keeping regardless of any base alignment. |
+
+### `src/common/json.c`
+
+_Base ships no linkable JSON parse/emit C library (confirmed: no libjson/libjansson, nothing in /usr/src/lib references jsmn/json_parse), so the emitter and jsmn wrapper are all KEEP; two honest adjacencies exist (vis(3) for escaping, wctomb/citrus for UTF-8) but neither is a clean drop-in for JSON semantics._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| json_writer_init | Initialize a JSON emitter writer over a struct buf (depth/type/count state machine). | — | **KEEP** | S | no base JSON emitter; writer state is fugu-specific over fugu's struct buf | State-machine init; nothing in base to align to. |
+| json_pre / json_push / json_pop (emitter separator/nesting core) | Place commas, enforce key-before-value, push/pop object/array containers with depth/type checks. | — | **KEEP** | S | no base JSON serializer; this comma/nesting logic is the heart of a JSON emitter base does not provide | Tightly-cohesive static helpers; aborts via fugu-local fatalx (log.h:41, an err.h-style wrapper). No linkable equivalent. |
+| json_object_start / json_object_end / json_array_start / json_array_end | Public container open/close wrappers around json_push/json_pop. | — | **KEEP** | S | no base JSON emitter API | Thin public faces over the static core; grouped as the container API. |
+| json_emit_escaped | Write a JSON string literal: escape " \ \b \f \n \r \t, \u%04x for <0x20, UTF-8 passthrough. | vis(3) | **REFERENCE** | M | vis encodes C-style/\xNN, not JSON \uXXXX; escape set and UTF-8 passthrough differ, so it cannot be substituted | vis.h is base and linkable (vis(3) confirmed), but JSON escaping rules diverge from VIS_* encodings; mirror only the idiom (single-pass switch, control-char fallthrough), keep fugu's own JSON-correct table. |
+| json_key | Emit an object key (escaped) plus ':', with key-after-key / key-outside-object guards. | — | **KEEP** | S | no base JSON emitter | Reuses json_emit_escaped; same KEEP rationale as the emitter core. |
+| json_string / json_string_n | Emit a JSON string value from a C string or explicit length. | — | **KEEP** | S | no base JSON emitter | Wrap json_pre + json_emit_escaped. |
+| json_int | Emit a JSON integer via buf_printf("%lld"). | — | **KEEP** | S | no base JSON emitter; plain snprintf-style formatting needs no base lib | Number formatting is trivial; no base symbol to adopt. |
+| json_bool / json_null / json_raw | Emit literal true/false/null, or pre-formatted raw JSON, after a json_pre separator. | — | **KEEP** | S | no base JSON emitter | json_raw trusts caller-formatted text; no base analog. |
+| json_kv_string / json_kv_int / json_kv_bool | Convenience: emit key + typed value in one call. | — | **KEEP** | S | no base JSON emitter | Pure composition of json_key + value emitters; grouped as the kv convenience set. |
+| json_parse | Run jsmn over input, growing the token array on JSMN_ERROR_NOMEM (doubling, INT_MAX/2 overflow guard), via fugu's xreallocarray. | — | **KEEP** | S | no base JSON parser; jsmn.h is the charter-permitted vendored tokenizer | Confirmed nothing under /usr/src/lib references jsmn/json_parse; growth loop is idiomatic; uses fugu-local xreallocarray (util.h:25). |
+| json_doc_free | Free token array and clear the json_doc. | — | **KEEP** | S | frees jsmn token array; no base type involved | Trivial owner-side free. |
+| json_tok_type / json_tok_size / json_tok_streq | Accessors translating jsmn token fields into fugu's enum, plus a token==string compare against d->src. | — | **KEEP** | S | thin accessors over vendored jsmntok_t; no base equivalent | json_tok_streq uses memcmp(3) (base) internally, but the function itself is a jsmn-specific accessor — grouped as the token accessors. |
+| json_skip / json_obj_get | Recursively skip a token subtree; look up a value index by key in a jsmn OBJECT. | — | **KEEP** | S | jsmn flat-token traversal; no base JSON DOM/query exists | Core of the jsmn-index navigation; nothing in base to align to. |
+| utf8_encode | Encode a Unicode code point to 1-4 UTF-8 bytes into a struct buf (used when decoding \uXXXX escapes). | `/usr/src/lib/libc/citrus/citrus_utf8.c:258` wctomb(3) | **REFERENCE** | M | wctomb only emits UTF-8 when the process locale's ctype is UTF-8 (MB_CUR_MAX); a pledged privsep daemon may not guarantee that, so a deterministic hand-rolled encoder is safer | _citrus_utf8_ctype_wcrtomb at citrus_utf8.c:258 is the base encoder behind wctomb(3) (declared stdlib.h); mirror its bit layout but keep fugu's locale-independent version. Not a clean ADOPT due to locale dependence. |
+| hex4 | Parse exactly 4 hex digits into an unsigned int for \uXXXX decoding. | — | **KEEP** | S | fixed-4-digit in-place hex scan; strtoul/strtonum don't fit (no terminator, would over-read) | Deliberately bespoke; base integer parsers can't bound to 4 chars without a copy. |
+| json_tok_strdup | Allocate an unescaped copy of a JSON string token: handle \" \\ \/ \b\f\n\r\t and \uXXXX incl. surrogate pairs, building into a struct buf. | unvis(3) | **REFERENCE** | M | strunvis decodes vis(3)/C-style escapes, not JSON's \uXXXX surrogate-pair rules; substituting it would mis-handle Unicode escapes | unvis(3)/strunvis is base and linkable but speaks a different escape grammar; mirror the streaming-decode idiom only. Calls fugu's utf8_encode/hex4 for the JSON-specific parts. |
+
+### `src/common/imsgproto.c`
+
+_fugu uses the CURRENT rewritten imsg API (imsgbuf_*, imsg_get_*, imsg_composev, imsg_forward) idiomatically; the thin plumbing wrappers are REFERENCE (already aligned to base, nothing more to adopt), and the chunking/reassembly layer is correctly KEEP because base imsg has no chunking._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| ip_init | Zero the imsgproto state and initialize the imsgbuf over fd via imsgbuf_init(); returns -1 on failure. | `/usr/src/usr.sbin/bgpd/bgpd.c:278` imsg_init(3) | **REFERENCE** | S | Wraps current imsgbuf_init(&ibuf, fd)==-1 idiom 1:1 (matches bgpd.c:278; symbol at imsg.h:141). Already idiomatic; cannot 'adopt' since the value-add is the surrounding chunking struct. No change needed — confirms fugu is on the NEW API, not the removed imsg_init. | Confirmed fugu uses the rewritten API: imsgbuf_init present at /usr/include/imsg.h:141; old imsg_init is gone from the header. Linkable from libutil (/usr/src/lib/libutil/imsg.c). |
+| ip_clear | Release all reassembly slots, then imsgbuf_clear() the channel. | imsg_init(3) | **REFERENCE** | S | imsgbuf_clear at imsg.h:147 is used verbatim; the reasm_release loop around it is fugu-specific teardown with no base analog. Correct as-is. | Half of this function (the reasm loop) is KEEP-shaped; the imsg half is already the current idiom. Net: aligned, no action. |
+| ip_fd | Accessor returning the underlying channel fd (ip->ibuf.fd). | imsg_init(3) | **REFERENCE** | S | Reads the public imsgbuf.fd field (declared in struct imsgbuf, imsg.h). Base exposes no fd-getter function, so a one-line accessor over the public field is the idiomatic approach (bgpd/iked do the same inline). Keep. | Not an ADOPT: there is no imsgbuf_fd() symbol in base; the field is public and read directly elsewhere in /usr/src. |
+| ip_compose | Frame a logical message into one or more imsg chunks (each prefixed with struct fugu_chdr) and queue them via imsg_composev; zero-length sent as one empty chunk. | `/usr/src/usr.sbin/httpd/proc.c:724` imsg_init(3) | **REFERENCE** | M | Per-chunk send uses imsg_composev(&ibuf, type, id, pid, fd, iov, iovcnt) exactly as httpd/proc.c:724 (signature at imsg.h:165, man-confirmed). The chunking/splitting loop has NO base equivalent (MAX_IMSGSIZE=16384 cap, base imsg never fragments). The imsg call is aligned; the framing is intrinsically fugu's. | imsg_composev confirmed in man imsg_init(3) with matching signature. The fugu_chdr framing over it is the KEEP part; the call idiom itself is already correct. |
+| ip_flush | Block until all queued frames are written (wraps imsgbuf_flush). | imsg_init(3) | **REFERENCE** | S | Pure 1:1 pass-through to imsgbuf_flush (imsg.h:146). Idiomatic; trivial wrapper for a uniform ip_* surface. No change. | Wrapper exists only to keep callers off the raw imsgbuf; base symbol is current and correct. |
+| ip_write | Single non-blocking write pass (wraps imsgbuf_write). | imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_write (imsg.h:145). Current API, idiomatic. No change. | Part of the event-loop write path; mirrors how bgpd drives imsgbuf_write. |
+| ip_read | Read pending bytes into the buffer (wraps imsgbuf_read); 1 ok / 0 EOF / -1 error. | `/usr/src/usr.sbin/bgpd/bgpd.c:1280` imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_read (imsg.h:144); fugu's 1/0/-1 contract matches the base return convention used at bgpd.c:1280. Already idiomatic. | Confirms current API; the removed imsg_read/imsg_read_nb is not used. |
+| ip_get | Pull the next fully reassembled logical message, draining buffered chunks via imsgbuf_get and folding each through handle_chunk. | imsg_init(3) | **REFERENCE** | M | The drain loop uses imsgbuf_get (imsg.h:149, returns 1/0/-1) and imsg_free (imsg.h:172) per the documented contract. The multi-frame reassembly accumulation it wraps has NO base analog. imsg calls are aligned; reassembly is fugu's. | imsgbuf_get is the current accessor (old imsg_get returns ssize_t and still exists at imsg.h, but fugu correctly uses the newer int-returning imsgbuf_get). Loop structure mirrors base imsgbuf_get drain loops. |
+| ip_msg_free | Free the reassembled message buffer and zero the struct fugu_msg. | — | **KEEP** | S | Frees fugu_msg.data (fugu-owned heap), not a base struct imsg; imsg_free is for struct imsg and does not apply here. | Distinct from imsg_free(&imsg) which fugu calls separately in ip_get. No base equivalent for the reassembled-message struct. |
+| ip_frame_get | Pull one raw frame for the relay without reassembly (wraps imsgbuf_get); 1/0/-1. | imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_get (imsg.h:149). Idiomatic raw-frame pull used by the parent relay path; current API. No change. | Same accessor as ip_get's loop but exposed unwrapped for the relay that forwards by id without touching payload. |
+| ip_forward | Relay a received frame to another channel unaltered via imsg_forward (the parent's routing primitive, by imsg_get_id). | `/usr/src/sbin/dhcpleased/dhcpleased.c:716` imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsg_forward(&dst.ibuf, imsg) exactly as dhcpleased.c:716 and iked/control.c:345 (symbol at imsg.h:162). This is precisely the base relay idiom — fugu is correctly using imsg_forward rather than re-composing. No change. | Strong confirmation fugu mirrors base privsep relay design: forward-by-id without reassembly, the same pattern dhcpleased/iked/bgpd use. The current imsg_forward is the right tool. |
+| reasm_find | Linear-scan the IP_MAXASM reassembly slots for the in-progress partial matching (src, msgid). | — | **KEEP** | S | no base option: base imsg has no chunking/reassembly layer, so no lookup primitive exists to adopt. | Intrinsic to fugu's logical-message-over-imsg framing. Fixed 8-slot table; no base data structure applies. |
+| reasm_alloc | Acquire a reassembly slot, evicting the oldest (by seq) partial when all IP_MAXASM slots are busy to prevent channel wedging. | — | **KEEP** | M | no base option: oldest-first eviction over a bounded reassembly table is fugu's anti-DoS policy; base imsg never reassembles, so nothing to adopt. | Security-relevant bounded-slot eviction. Charter-aligned (defends against a hostile peer holding partials open). Pure fugu logic. |
+| reasm_reserve | Grow a partial's buffer toward (but never past) its advertised total, doubling capacity, using xreallocarray. | — | **KEEP** | S | no base option: the grow-to-delivered-bytes (not advertised-total) anti-DoS policy is fugu-specific; the underlying reallocarray is already base via the project's xreallocarray wrapper. | The only base API in play here is reallocarray(3), already adopted transitively through util.h's xreallocarray. The capping policy itself has no base analog. |
+| reasm_release | Free a partial's buffer and zero the slot. | — | **KEEP** | S | no base option: frees fugu's reassembly slot; trivial free()+memset with no base equivalent. | Cleanup helper for the reassembly table. free(3) is the only base call and needs no wrapper. |
+| handle_chunk | Fold one received imsg chunk into reassembly state: parse fugu_chdr via imsg_get_buf, validate bounds/ordering, fast-path single-chunk messages, and complete or accumulate; 1/0/-1. | imsg_init(3) | **REFERENCE** | L | Frame-field access uses the current accessors imsg_get_type (imsg.h:161), imsg_get_id (158), imsg_get_len (159), imsg_get_buf (155, man-confirmed return 0/-1) — all idiomatic and correct. The reassembly state machine, bounds-checking, and ordering enforcement around them have NO base equivalent. Accessors aligned; reassembly is KEEP-shaped. | fugu correctly uses imsg_get_buf (the copy-out accessor, returns 0/-1 per man imsg_init(3)) rather than poking imsg.data directly — the recommended current idiom. The validation/reassembly logic is the security core and is intrinsically fugu's. Largest function; bulk of it is KEEP, but the imsg surface is exemplary REFERENCE. |
+
+### `src/common/http.c`
+
+_A self-contained, hostile-server-hardened incremental HTTP/1.1 client; no linkable base HTTP parser exists, so the protocol logic stays KEEP, but every idiom should be aligned to ftp(1)'s url_get/save_chunked (REFERENCE), and the one true libc win (strtonum) is already adopted._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| http_build_request | Serialise an HTTP/1.1 request (request-line, Host, Connection: close, caller headers, optional Content-Length, body) into a buf. | `/usr/src/usr.bin/ftp/fetch.c:706` | **REFERENCE** | S | fetch.c builds the exact same idiom — "GET %s HTTP/1.1\r\nConnection: close\r\nHost: %s\r\n" via ftp_printf to a FILE* (fetch.c:706-787). It is program-internal, not linkable. Mirror two edge cases fugu omits: (1) IPv6 literal hosts must be bracketed and the scope/%-suffix stripped before the Host header (fetch.c:740-748); (2) omit a default :80/:443 port from Host since some servers choke (fetch.c:766-772). fugu takes host verbatim, so the caller must pre-format these. | Charter-clean (buf_printf only). Not an ADOPT: no linkable request-builder in base; align idioms only. |
+| http_resp_init / http_resp_clear / http_resp_done | Lifecycle for the incremental response parser: zero-init state (status=HTTP_STATUS, content_length=-1, default maxline, init line/ctype bufs), free the bufs, and report completion. | — | **KEEP** | S | Plain struct lifecycle around fugu's own buf type; no base library object models an incremental HTTP response. ftp(1) keeps this state as locals in url_get, not a reusable object. | Grouped: three trivial cohesive accessors/lifecycle helpers over struct http_resp. Nothing to adopt. |
+| http_resp_push | Core incremental state machine: feed arbitrary response bytes, split lines (capped at maxline), de-frame body by Content-Length / chunked / read-until-close, and hand decoded body bytes to a callback. | `/usr/src/usr.bin/ftp/fetch.c:1154` | **REFERENCE** | M | Base has NO linkable incremental HTTP body de-framer. ftp(1) solves the same problem but over a buffered FILE* with getline()/fread() (blocking, program-internal): save_chunked (fetch.c:1154-1219) and the Content-Length read path. fugu's push-model parser is actually MORE robust than the reference for its threat model (no FILE* buffering, works on partial network reads, every line maxline-capped). Worth mirroring from save_chunked: it strips the optional chunk extension with header[strcspn(header,"; \t\r\n")]='\0' (fetch.c:1167) and verifies the per-chunk trailing CRLF byte-exactly (fetch.c:1196-1207) — fugu already does both. Note fugu correctly ignores Content-Length when chunked is set, matching fetch.c:993-994. | This is the heart of the unit. KEEP-the-code, REFERENCE-the-idioms. No base linkable equivalent (would need a ports HTTP lib = out of charter). |
+| http_resp_eof | Signal connection close: completes an EOF-framed body (HTTP_BODY_EOF -> HTTP_DONE), else treats a mid-message close as truncation (-1). | — | **KEEP** | S | Trivial terminal-state transition specific to fugu's push API; base ftp(1) detects this implicitly via getline()/fread() returning EOF, not as a separable function. | No symbol to adopt. |
+| http_line | Dispatch one complete (CRLF-stripped) line to the right handler based on parser state: status, header, end-of-headers, chunk-size, or trailer. | `/usr/src/usr.bin/ftp/fetch.c:868` | **REFERENCE** | S | fetch.c's header-reading for(;;) loop (fetch.c:868-936) is the analogous line dispatcher but inlined into url_get and FILE*-driven; not linkable. fugu's split into a state-switch is cleaner for a push parser. Align the trailing-whitespace strip: fetch.c trims trailing \r\n AND spaces/tabs (fetch.c:880-882), whereas fugu only strips a single trailing \r — consider trimming trailing SP/HT too for robustness against sloppy servers. | Internal dispatcher; REFERENCE for the whitespace-trim edge case only. |
+| http_status | Parse the status line: require "HTTP/" prefix, skip the version token and spaces, read exactly 3 status digits into r->status. | `/usr/src/usr.bin/ftp/fetch.c:806` strtonum(3) | **REFERENCE** | S | fetch.c parses status via cp=strchr(buf,' '); cp++; strtonum(cp,200,503,&errstr) (fetch.c:806-812) — simpler, and notably it reuses the libc strtonum win for the numeric conversion. But fetch.c does NOT validate the "HTTP/" prefix and clamps the accepted code range to 200..503; fugu's explicit prefix check + 3-digit scan is stricter and better for a hostile peer. Keep fugu's logic; the REFERENCE is the strtonum idiom (already used elsewhere in fugu). | Hand-rolled digit scan is justified by the prefix-validation requirement; strtonum on the substring is an option but loses the 'exactly 3 digits' guarantee. |
+| http_header | Parse one header line: split on ':', trim OWS around the value, and record the three headers fugu cares about (Transfer-Encoding: chunked, Content-Length via strtonum, Content-Type). | `/usr/src/usr.bin/ftp/fetch.c:912` strtonum(3) | **REFERENCE** | S | fugu already mirrors fetch.c almost exactly: strncasecmp on the header name then strtonum(val,0,LLONG_MAX,&errstr) for Content-Length (fetch.c:912-918) and strcasecmp for Transfer-Encoding chunked (fetch.c:975-978). The strtonum usage IS the adopted libc win. Difference: fetch.c skips leading OWS with strspn(cp," \t") but doesn't trim trailing OWS; fugu trims both — keep fugu's. One alignment note: fetch.c compares the whole "chunked" value with strcasecmp (exact), fugu uses a ci_contains substring match which is more lenient and matches multi-value TE lists like "gzip, chunked". | Already well-aligned to base idioms; strtonum already adopted. No new linkable symbol beyond strtonum. |
+| http_endhdrs | On the blank line ending headers, pick the body-framing mode: no-body statuses (1xx/204/304), then chunked > Content-Length > read-until-close, per RFC 7230. | `/usr/src/usr.bin/ftp/fetch.c:993` | **REFERENCE** | S | fetch.c encodes the same 'chunked wins over Content-Length' rule (sets filesize=-1 when chunked, fetch.c:993-994) but does NOT special-case 204/304/1xx bodyless responses — fugu's handling here is more correct than the reference. Program-internal regardless. Keep fugu's; cite fetch.c only for the precedence rule. | fugu is stricter than base here; pure REFERENCE for the one shared rule. |
+| http_chunksize | Parse a hex chunk-size line with overflow checking, stop at ';'/non-hex (extension), require >=1 digit, route size 0 -> trailer and size>0 -> chunk-data. | `/usr/src/usr.bin/ftp/fetch.c:1169` | **REFERENCE** | M | Direct analogue of save_chunked's chunksize = strtoul(header,&end,16) with the header[strcspn(header,"; \t\r\n")]='\0' extension strip (fetch.c:1167-1172). fetch.c relies on strtoul + errno + an explicit chunksize>INT_MAX cap; fugu hand-rolls the hex parse with an inline (size > (LLONG_MAX-d)/16) overflow guard and a LLONG_MAX ceiling. fugu could ADOPT strtoul(3) to shrink the loop, but only after NUL-terminating the line (it has the line buf, so feasible) — fetch.c's INT_MAX clamp is a useful sanity cap fugu may want to mirror to bound a single chunk. Net: REFERENCE; a strtoul refactor is optional and not a clean drop-in because fugu parses a non-NUL-terminated (ptr,len) slice. | strtoul is linkable libc, but the (ptr,len) interface and the desire for an explicit ceiling make the hand-rolled scan reasonable; treat strtoul as an optional simplification, not a mandated ADOPT. |
+| ci_contains | Case-insensitive substring search over a length-bounded, non-NUL-terminated haystack (used to spot "chunked" inside a Transfer-Encoding value list). | `/usr/src/lib/libc/string/strcasestr.c:1` strcasestr(3) | **KEEP** | S | strcasestr(3) is real linkable libc (declared /usr/include/string.h:133, man strcasestr(3)) and does exactly this — BUT it requires a NUL-terminated haystack, while ci_contains operates on a (ptr,len) slice of the un-terminated line buffer. Adopting it would force a copy-and-NUL-terminate of the header value first, which is strictly more work and more allocation than the current strncasecmp sliding-window. So no clean drop-in; keep the helper. (memmem(3) is the case-sensitive length-bounded cousin but offers no case-folding.) | Verified strcasestr lives under lib/libc (linkable, not program-static), so the symbol is legit — it just doesn't fit fugu's non-terminated-buffer contract. This is the honest 'looks adoptable but isn't a drop-in' case. |
+
+### `src/common/sse.c`
+
+_Incremental push-parser for the SSE (text/event-stream) wire format the Anthropic Messages API streams over; base has zero SSE support and no streaming line-splitter that fits a cross-chunk push model, so the whole unit is KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| sse_init / sse_clear | Lifecycle: zero the parser, init the three growable buffers (line/event/data), set the callback and the maxline/maxtotal hostile-stream caps; sse_clear frees buffers and re-zeros. | — | **KEEP** | S | no base SSE/EventSource type to init or tear down; buffer mgmt is delegated to buf.* (its own unit) | Trivial struct setup over fugu's own struct sse_parser + struct buf. Nothing in base to align to. Grouped as the tight init/teardown pair: both just memset + buf_init/buf_free over the same fields. |
+| sse_push | Feed arbitrary-sized byte chunks; split on CR/LF/CRLF (swallowing the LF of a CRLF via after_cr state), accumulate partial lines across calls into p->line, enforce per-line maxline cap, and hand complete lines to sse_line. | getline(3)/getdelim(3) and strsep(3) exist but neither fits a stateful cross-chunk push parser | **KEEP** | M | no base option: getline/getdelim are pull-model over FILE* (need a seekable stream, not libtls chunks across a privsep boundary); strsep splits a complete in-memory string, not a partial byte stream with CRLF-straddling state | Confirmed no base SSE: ssh server apropos sse and grep -rln 'event-stream\|server-sent\|EventSource' /usr/src both empty. The CR/LF/CRLF + maxline-bounded incremental line assembly is exactly the part base does NOT provide as a library; it is the reason this file exists. Buffer growth itself goes through buf_putc/buf_reset (buf.c unit) where any open_memstream(3)/ibuf overlap would be evaluated, not here. |
+| sse_line | Parse one complete line: blank line dispatches the event; ':' comment ignored; split field name at first colon with the one-optional-leading-space rule; handle event:/data: (data lines joined by '\n'), enforce maxtotal cap; accept-and-ignore id/retry/unknown. | — | **KEEP** | M | no base option: this is the EventSource spec field grammar (W3C), not implemented anywhere in OpenBSD base | Field-name/value split could nominally use memchr(3) for the colon scan instead of the hand-rolled for-loop, but that is a micro-cleanup inside buf.* territory, not an idiomatic base equivalent for the function. The spec semantics (leading-space strip, data '\n' join, maxtotal guard) have no base counterpart to adopt or reference. |
+| sse_dispatch | Finalize an accumulated event: strip the trailing '\n' from data per spec, default event type to "message", NUL-terminate event and data buffers (uncounted), invoke the user callback, then reset event/data state for the next event. | — | **KEEP** | S | no base option: EventSource dispatch/default-event-type semantics are not in base | Pure SSE-spec dispatch logic plus a callback invocation; nothing linkable in base mirrors it. The NUL-terminate-uncounted idiom is a buf.* convention (documented in buf.h). |
+
+### `src/common/conf.c`
+
+_Config model is app-specific KEEP throughout; the one base-overlap is conf_check_secrecy, which mirrors base's verbatim-copied check_file_secrecy (program-internal, REFERENCE) but diverges in signature (path/stat vs fd/fstat -> TOCTOU) and policy._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| conf_check_secrecy | StrictModes guard: a secrets/key file must be owned by uid and mode 0600 before fugu reads it; warns and returns -1 otherwise. | `/usr/src/usr.sbin/smtpd/parse.y:check_file_secrecy (canonical full version; identical body in httpd/parse.y, iked/parse.y; bgpd/parse.y:4007 is the stripped 7.9-current variant)` | **REFERENCE** | S | Base check_file_secrecy is a per-program static in parse.y (not in lib/ or /usr/include), so it is not linkable; align idioms only. Deltas to mirror: (1) base takes an OPEN fd and uses fstat() to close the TOCTOU window -- fugu takes a path and re-stat()s by name; if fugu later open()s the same path it can be swapped. Prefer open()+fstat(fileno). (2) base permits owner==root OR getuid(); fugu requires st_uid==uid exactly -- stricter and arguably correct for a privsep secret, but note the divergence for reviewers. (3) base rejects S_IWGRP\|S_IXGRP\|S_IRWXO (allows group/world READ); fugu rejects S_IRWXG\|S_IRWXO i.e. demands true 0600 -- again stricter, appropriate for secrets. fugu's stricter policy is defensible; the actionable win is matching the fd/fstat shape so the code reads like the base idiom reviewers know. | Confirmed no linkable libutil/libc secrecy helper: grep of /usr/src/lib and /usr/include for check_file_secrecy/secrecy/StrictModes returned nothing. So this can never become ADOPT -- REFERENCE is the ceiling. fugu's redacting/warning via log_warn/log_warnx already matches base style. |
+| conf_init / conf_match_new / conf_clear (TAILQ lifecycle group) | Initialize the conf (zero global, TAILQ_INIT matches), allocate+append a match block, and tear down all match blocks + global freeing strings. | `/usr/include/sys/queue.h:439 (TAILQ_FOREACH), :465 (TAILQ_INIT); INSERT_TAIL/REMOVE/FIRST in same header` queue(3) | **ADOPT** | S | Already adopted -- these helpers use base sys/queue.h TAILQ macros idiomatically (TAILQ_INIT, INSERT_TAIL, FOREACH, FIRST, REMOVE). No change needed; recorded as ADOPT because the list machinery IS the base equivalent and fugu uses it correctly. xcalloc/xstrdup wrappers are fugu/ssh-style (ssh xmalloc.c is program-internal, not a base lib), out of scope here. | 83 TAILQ references in queue.h confirm base provenance. Grouped because all three are thin lifecycle wrappers around the same TAILQ head and share no other logic. |
+| setstr / conf_set_string / conf_set_flag / conf_settings_clear | Field setters: free-then-xstrdup a string field, OR a CS_* presence bit into the mask, set a scalar's bit, and free all string fields + zero the struct. | — | **KEEP** | S | App-specific bitmask/presence model over fugu's own conf_settings struct; no base API expresses 'set bit N and dup string'. free()/strdup primitives are base but the orchestration is bespoke. | conf_settings_clear must stay in sync with the struct's string members in conf.h:51-62 (model, search_provider, anthropic_key, kagi_token, *_file) -- maintenance hazard if a field is added, but not a base-equivalence question. |
+| settings_merge / match_applies / conf_resolve / conf_overlay (merge engine group) | Layered config resolution: copy masked fields from src into dst, test whether a match block applies to a user/group set, collapse global+matches with first-match-wins per key (locked mask), then overlay the user's personal settings. | — | **KEEP** | M | No base option -- the bitmask merge / first-match-wins-per-key semantics are fugu-specific policy over fugu's own structs; sshd's Match resolution (servconf.c) is program-internal and not a linkable analogue worth REFERENCE since fugu's model differs (per-key locking vs sshd's per-line). | Grouped as one cohesive engine: settings_merge is the primitive, match_applies the predicate, conf_resolve/conf_overlay the public drivers. strcmp on group names is plain libc. If anything, conf.h's comment already correctly frames it as 'sshd Match-style' -- documentation alignment only, not adoption. |
+| conf_settings_print | Render effective settings for `fugu -n`, printing each set field and redacting secret values as "***" while still showing *_file paths. | — | **KEEP** | S | App-specific serializer over fugu's struct; plain fprintf(3). Secret redaction policy (print *** for key/token, show file paths) is a fugu charter requirement with no base equivalent. | Good charter compliance: secrets never emitted verbatim (conf.c:222-226), consistent with 'secrets never in journal'. fprintf is base libc but the field-by-field logic is bespoke. |
+
+### `src/common/confload.c`
+
+_Single function conf_load: config-load orchestration whose libc primitives (getpwuid/getgrouplist/getgrgid) are already idiomatic base calls, but whose group-name resolution and overall load flow should be aligned to OpenSSH groupaccess.c / auth.c idioms — REFERENCE, not a single linkable lib._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| conf_load (group resolution: getgrouplist + getgrgid + xstrdup loop, lines 62-79) | Resolve the invoking user's supplementary groups to group *names* so the config 'Match group' scoping can compare against them. | `/usr/src/usr.bin/ssh/groupaccess.c:47` getgrouplist(3) | **REFERENCE** | S | getgrouplist(3) itself is confirmed linkable libc (/usr/include/unistd.h:499; impl /usr/src/lib/libc/gen/getgrouplist.c) and fugu's call is correct/idiomatic. But the surrounding 'getgrouplist -> loop getgrgid -> xstrdup(gr->gr_name)' helper is exactly OpenSSH ga_init (groupaccess.c:47-67); ga_match (groupaccess.c:73) is the matching half. There is no linkable libutil wrapper for the name-resolution loop, so this is REFERENCE: mirror ga_init's structure. Note divergence: OpenSSH uses a fixed NGROUPS_MAX+1 array; fugu does a grow-and-retry on the documented getgrouplist==-1 'list too small' return — fugu's is arguably more correct but deviates from the base idiom; align deliberately one way or the other. | Verified: man 3 getgrouplist (NAME/SYNOPSIS/RETURN confirm int getgrouplist(const char*,gid_t,gid_t*,int*), -1 == too small). Header /usr/include/unistd.h:499. Base callers: /usr/src/usr.bin/ssh/groupaccess.c, /usr/src/usr.sbin/authpf/authpf.c, /usr/src/sbin/mountd/mountd.c. groupaccess.c:63-65 is the byte-for-byte analogue of confload.c:73-78. getgrgid(3) likewise libc. |
+| conf_load (overall load flow: getpwuid/getuid, HOME fallback, optional /etc/fugu.conf via access(), StrictModes secrecy + overlay of ~/.fugu/fugu.conf) | Build the effective config for the invoking user: identify user, locate home, parse optional system defaults, then StrictModes-check and overlay the per-user secrets file. | `/usr/src/usr.bin/ssh/auth.c:352` getpwuid(3) | **REFERENCE** | M | The primitives are all already idiomatic base libc (getpwuid(getuid()) is the canonical pattern, seen in id.c, ftp/main.c, cu.c, etc.; HOME-from-getenv-then-pw->pw_dir fallback and the truncation-checked snprintf("%s/%s") path build are the OpenSSH auth.c idiom at auth.c:342/352). No single linkable library bundles 'load+overlay+StrictModes' — that is per-daemon glue. So KEEP-the-glue / REFERENCE-the-idioms: align the home-dir fallback and path construction to auth.c, and (handled in conf.c's conf_check_secrecy) mirror OpenSSH's secure-path/StrictModes ownership+mode check. resource handling (xreallocarray/xstrdup/free of gids+gnames, conf_clear on every exit) is correct; the rc/goto-done cleanup is sound. | Confirmed idioms: getpwuid(getuid()) base callers include /usr/src/usr.bin/id/id.c, /usr/src/usr.bin/ftp/main.c, /usr/src/usr.bin/cu/cu.c. pw->pw_dir as home fallback: /usr/src/usr.bin/ssh/auth.c:342, auth-rhosts.c:211. snprintf(ret,sizeof(ret),"%s/%s",pw->pw_dir,file) truncation idiom: /usr/src/usr.bin/ssh/auth.c:352. The StrictModes ownership/permission enforcement itself lives in conf_check_secrecy (conf.c, out of scope for this file) and is the OpenSSH secure_filename/auth_secure_path analogue — flag for conf.c's audit. Everything here stays in fugu (KEEP as code) while aligning idioms (REFERENCE). |
+
+### `src/common/parse.y`
+
+_Standard OpenBSD parse.y descendant; libc primitives (strtonum/bsearch/vasprintf) already idiomatic, but the lexer is forked from an OLDER httpd template — it has drifted behind current httpd/parse.y on three points: igetc indirection layer, growable ungetbuf, and check_file_secrecy in pushfile. These are REFERENCE alignments (httpd is a program, not a linkable lib), not ADOPT._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| yyerror | Format a parse error with file:lineno prefix and route it to the logger; bumps file->errors. | `/usr/src/lib/libc/stdio/vasprintf.c` vasprintf(3) (under printf(3)) | **REFERENCE** | S | Already uses vasprintf correctly and matches the httpd yyerror shape. Only nit vs template: fugu logs via log_warnx; httpd uses the same pattern. No real change needed; keep aligned to httpd's yyerror. | vasprintf is a true libc symbol (lib/libc/stdio). The yyerror skeleton itself lives only inside base PROGRAMS (httpd/parse.y), so it is a REFERENCE idiom, not a linkable API. fugu's version is faithful. |
+| kw_cmp | strcmp comparator for bsearch over the keyword table. | `/usr/src/usr.sbin/httpd/parse.y:1500` bsearch(3) | **KEEP** | S | Trivial 1-line comparator; identical to every base parse.y. Nothing to adopt — it IS the idiom. | bsearch is libc but the comparator is caller-supplied by definition; cannot be 'adopted' from base. Matches httpd/ftpd/relayd verbatim. Leave as-is. |
+| lookup | Binary-search the sorted keyword[] table to map an identifier to a token (or STRING). | `/usr/src/usr.sbin/httpd/parse.y:1505` bsearch(3) | **KEEP** | S | Already the canonical sorted-table + bsearch idiom; keyword list is fugu-specific so no base table to reuse. | bsearch confirmed in libc (man 3 bsearch). The 'must stay sorted' table is the standard parse.y pattern; fugu follows it correctly. No drift. |
+| lgetc | Lexer input: returns next char honoring quote mode, line-continuation backslash-newline, macro parsebuf, and pushback buffer; pops included files at EOF. | `/usr/src/usr.sbin/httpd/parse.y:1561` | **REFERENCE** | M | DRIFT: fugu's lgetc still embeds the old parsebuf/parseindex + global pushback logic inline. Current httpd factored the raw getc/unget into a separate igetc() (parse.y:1540) and added an eof_reached 'fake EOL' so the last unterminated line counts correctly. Aligning means splitting lgetc into lgetc+igetc and adding eof_reached. | Confirmed: httpd lgetc now calls igetc() and has the eof_reached fake-newline branch (parse.y:1586-1601) that fugu lacks. Pure REFERENCE: lives in a base PROGRAM, not linkable. Low correctness risk but real edge-case (unterminated final line) drift. |
+| lungetc | Push one char back onto the input; backstops the macro parsebuf, else uses a fixed 128-byte pushback_buffer. | `/usr/src/usr.sbin/httpd/parse.y:1605` reallocarray(3) | **REFERENCE** | M | DRIFT: fugu uses a fixed global char pushback_buffer[128] (MAXPUSHBACK) that returns EOF on overflow. Current httpd moved the unget buffer INTO struct file as a growable ungetbuf/ungetpos/ungetsize, doubled via reallocarray (parse.y:1610-1616). This is what makes the START_EXPAND/DONE_EXPAND macro scheme possible and removes the 128-char ceiling. | reallocarray is libc (man 3 reallocarray). The growable-ungetbuf design is the httpd template's; align by moving pushback into struct file. This change is the prerequisite for the modern macro-expansion fix below. |
+| findeol | Error recovery: consume input up to next newline/EOF and return ERROR so the grammar resyncs. | `/usr/src/usr.sbin/httpd/parse.y:1621` | **REFERENCE** | S | DRIFT (minor): fugu's findeol clears parsebuf and hand-drains the global pushback_buffer. Current httpd findeol is simpler — just loops on lgetc(0) — because pushback now lives in struct file and igetc handles it. Once lungetc/igetc are aligned, fugu's findeol collapses to httpd's 9-line form. | Confirmed httpd findeol body (parse.y:1621-1636) has no parsebuf/pushback poking. REFERENCE only (program-internal). Trivial once the unget refactor lands. |
+| yylex | Tokenizer: skips ws/comments, expands $macros, scans quoted strings, numbers (strtonum), and bare identifiers; returns tokens. | `/usr/src/usr.sbin/httpd/parse.y:1656` strtonum(3) | **REFERENCE** | L | DRIFT: fugu expands macros the OLD way (set parsebuf=val; parseindex=0; goto top). Current httpd pushes the macro value back through lungetc bracketed by START_EXPAND(1)/DONE_EXPAND(2) sentinels and guards re-expansion with `expanding` and `!expanding` (parse.y:1656,1679-1684; igetc 1550-1553; defines 1534-1537). This kills the separate parsebuf code path entirely and allows nested/recursive macro use. strtonum usage is already correct and idiomatic. | **[verified correction: NO expand_macros fn exists in httpd parse.y; real mechanism is START_EXPAND/DONE_EXPAND sentinels + `expanding` flag read by igetc (parse.y:1540,1550-1553)]** There is NO function named expand_macros in current httpd/parse.y (grep found it only in vendored perl) — the lead's wording is imprecise; the real mechanism is the inline START_EXPAND/DONE_EXPAND + `expanding` flag interpreted by igetc. strtonum confirmed libc (man 3 strtonum). REFERENCE: template lives in a base program. This is the highest-effort alignment because removing parsebuf touches lgetc, lungetc, findeol, and yylex together. |
+| pushfile | Open an include/config file, allocate a struct file, push it on the include stack. | `/usr/src/usr.sbin/httpd/parse.y:1815` fstat(2) | **REFERENCE** | M | DRIFT + CHARTER-relevant: fugu's pushfile has NO secrecy check and links files via a bare nfile->prev pointer (and oddly never inserts onto a list — the caller assigns file=nfile). Current httpd takes a `secret` arg and, when set, runs check_file_secrecy(fileno, name) (parse.y:1833-1839) rejecting group/world-readable or non-owner files, and tracks files on a TAILQ. For fugu, whose charter says secrets never leak, the anthropic_key_file / kagi_token_file settings argue for adopting check_file_secrecy on the config (and key) files. | check_file_secrecy is a per-program helper (httpd/parse.y:1795), NOT a libc symbol — REFERENCE, copy the ~18 lines. Underlying fstat(2) and the S_IWGRP/S_IXGRP/S_IRWXO mode test are base. High value given fugu stores API keys; this is the most security-relevant drift in the file. |
+| popfile | Close current file, propagate its error count to the parent, pop the include stack. | `/usr/src/usr.sbin/httpd/parse.y:1855` | **REFERENCE** | S | DRIFT (minor): fugu walks file->prev; httpd uses TAILQ_PREV over a `files` TAILQ and frees the per-file ungetbuf. If the lungetc/ungetbuf and TAILQ alignments are adopted, popfile must also free ungetbuf and TAILQ_REMOVE. Otherwise functionally equivalent. | **[verified correction: popfile is :1855; :1844 is inside pushfile]** REFERENCE: program-internal. Couples to the pushfile/lungetc refactors; on its own fugu's prev-pointer version is correct. |
+| symset | Define/replace a macro symbol (name=val), honoring persistent (-D cmdline) entries. | `/usr/src/usr.sbin/httpd/parse.y` | **KEEP** | S | Matches the base template; uses fugu's xstrdup/xcalloc wrappers. No base library API for a symbol table — it is the standard per-parser TAILQ idiom. | Identical in spirit to httpd symset. No linkable base equivalent (a macro symbol table is parser-local). fugu also lacks the cmdopts/symset_cmdline path but that is feature scope, not drift. Keep. |
+| symget | Look up a macro symbol's value by name. | `/usr/src/usr.sbin/httpd/parse.y` | **KEEP** | S | Standard TAILQ_FOREACH lookup; no base library symbol-table API to adopt. | Matches base template verbatim. Keep. |
+| conf_parse_file | Public entry point: set conf/cur, pushfile(filename), run yyparse, collect errors, drain the macro table so a re-parse starts clean. | `/usr/src/usr.sbin/httpd/parse.y:1880` | **REFERENCE** | S | fugu's driver matches the base parse_config shape (pushfile/topfile/yyparse/popfile/return errors?-1:0). Two alignments: (1) once pushfile gains a `secret` arg, pass the right flag here; (2) httpd's parse_config additionally walks symset cmdline opts and warns on unused symbols — optional. The macro-drain-on-exit is fugu's own (good hygiene for repeated parses). | REFERENCE: every base parser has this driver but it is program-internal, not linkable. fugu's is correct and idiomatic; the only meaningful follow-on is threading the check_file_secrecy `secret` flag through. |
+
+### `src/common/url.c`
+
+_Both functions are sound, hardened reimplementations with no linkable base equivalent; base only solves the same problems inside programs (ftp/httpd) — REFERENCE for idioms, but fugu's allowlist/anti-injection posture is stricter than base and worth keeping._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| url_parse | Parse an absolute http(s) URL into {https, host, port, path} with bounded fixed buffers; default port from scheme; drop fragment; reject userinfo-smuggling, non-host chars in host, and space/control bytes in path (anti request/Host smuggling). | `/usr/src/usr.bin/ftp/fetch.c:306` strtonum(3), strlcpy(3) | **REFERENCE** | M | No base library parses URLs (the only man -k url hits are curl, a ports lib). url_get() at fetch.c:306 is static/program-internal: scheme split fetch.c:351-373, userinfo+path split fetch.c:377-405, IPv6-bracket + port split fetch.c:497-514. Mirror its [IPv6]/port handling, but note divergences: fetch.c uses strrchr for ']' and last ':' (portnum, fetch.c:508-509) and defers host validity to getaddrinfo; fugu bounds the authority with strcspn/memchr and additionally enforces a host-char allowlist (url.c:101-106) and rejects control/space in path (url.c:108-113) — hardening base does NOT do. Keep fugu's stricter posture; align only edge-case handling. Underlying primitives strtonum/strlcpy/strlcat/strcspn/memchr are all base and already used idiomatically. | strtonum(3) confirmed in <stdlib.h>; strlcpy/strlcat(3) in <string.h>; strcspn declared at /usr/include/string.h:78. Port range check via strtonum(u->port,1,65535,&errstr) is the canonical base idiom. fugu's leftmost-bounded parse is arguably safer than fetch.c's strrchr against crafted authorities. Keep the function; treat fetch.c as a robustness checklist, not a source to link. |
+| url_encode | RFC 3986 percent-encode: emit alnum and unreserved (-_.~) verbatim, %XX-encode everything else (uppercase hex), writing into a struct buf. | `/usr/src/usr.sbin/httpd/httpd.c:682` | **REFERENCE** | S | No linkable percent-encoder in libc/libutil. Two base analogs, both program-internal: httpd.c:682 url_encode (same static char hex[]="0123456789ABCDEF" table, but a BLOCKLIST — only encodes space #%?\"&< plus <=0x1f/>=0x7f — and calloc's a buffer) and fetch.c:141 url_encode (to_encode() predicate, malloc, lowercase %02x). fugu's ALLOWLIST (keep only RFC3986 unreserved) is stricter and more correct for query/form-data than httpd's blocklist, and it streams into buf instead of allocating. Adopt nothing; cite httpd.c:682 as the table/idiom reference and keep fugu's allowlist. | isalnum from <ctype.h> (already included, url.c:19) is base/standard. fugu's uppercase-hex + unreserved-keep matches RFC 3986 sec 2.3 exactly; httpd's blocklist would leave e.g. '+' and ';' unencoded, which is wrong for form bodies. Keeping fugu's implementation is the right call; the base programs validate the hex-table idiom but should not be adopted. |
+
+### `src/common/html2text.c`
+
+_A deliberately-minimal HTML-to-text reducer; OpenBSD base has no HTML parser or entity decoder (lynx/w3m are ports), so the tag/entity/whitespace machinery is all KEEP; only the UTF-8 codepoint encoder has a base analog (wctomb(3) / less), and only the output sink overlaps open_memstream(3)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| html2text | Main single-pass linear scanner: strips tags, skips comments/declarations/PIs, scans tag names honoring quoted attrs, dispatches script/style raw-skip, break/paragraph tags, entities, and whitespace collapse. | — | **KEEP** | L | no base HTML parser; lynx/w3m are ports (/usr/ports/www/lynx, /usr/ports/www/w3m), nothing in /usr/src/usr.bin, no html header in /usr/include | Charter-correct: hand-rolled, strictly linear over hostile input, output capped at HTML2TEXT_MAXOUT. Base offers zero linkable HTML tokenizer, so this stays bespoke. |
+| emit_codepoint | Encodes a Unicode codepoint to UTF-8 by hand (rejecting controls, surrogates, out-of-range), 1-4 byte forms. | `/usr/src/usr.bin/less/cvt.c:97` wctomb(3) | **REFERENCE** | M | wctomb(3) is locale-dependent (LC_CTYPE) and only emits UTF-8 under a UTF-8 locale; the hand encoder is locale-agnostic and pledge-safe, so adopting wctomb would be a regression for this hostile-input path. Mirror less/cvt.c only for the encode idiom, keep the manual path. | wctomb declared in <stdlib.h> (confirmed man 3 wctomb). Canonical base call site is less/cvt.c:97 `dst += wctomb(dst, ch)`. Keep fugu's validation of surrogates/range which wctomb does not guarantee across locales. |
+| emit_named | Resolves a named HTML character reference (amp/lt/gt/nbsp/copy/mdash/...) from a small static table to its replacement text, else echoes &name; literally. | — | **KEEP** | M | no base HTML-entity decoder; mandoc chars.c maps roff special-char escapes (\(co), not HTML entity names | Table-driven and intentionally tiny. strncasecmp inside it is base libc (strings.h:75) but the entity table and fallback logic are fugu-specific. |
+| decode_entity | Parses an &...; reference starting at '&': numeric (&#...; / &#x...;) with digit/overflow clamping, or alphanumeric named refs, dispatching to emit_codepoint/emit_named; falls back to literal '&'. | — | **KEEP** | M | no base charref parser | Uses isxdigit/isdigit/tolower/isalnum (ctype.h:68-79, base libc) but the &#/&#x/&name; grammar and clamping are HTML-specific glue with no base equivalent. |
+| skip_raw | Skips raw CDATA-like content (script/style body) to the matching delimited close tag, refusing bare-prefix false matches like </scriptx> so hidden text cannot leak. | — | **KEEP** | M | no base HTML tokenizer; raw-text-element handling is HTML-spec-specific | Security-relevant (prevents script text leakage). strncasecmp/isspace are base libc; the close-tag delimiter logic is bespoke and correct to keep. |
+| is_break_tag / is_paragraph_tag / tag_is | Tight group: tag_is does a length+case-insensitive name compare; is_break_tag and is_paragraph_tag test membership in static block-level tag sets to decide line vs paragraph breaks. | strncasecmp(3) | **KEEP** | S | the case-insensitive compare is already base (strncasecmp, strings.h:75); the tag-name sets and break/paragraph classification are HTML semantics with no base source | tag_is already wraps base strncasecmp idiomatically (declared /usr/include/strings.h:75). The membership tables (p/br/div/li/... vs paragraph tags) are pure HTML policy; keep as-is. |
+| emit_byte / emit_space / emit_break | Tight group: deferred-whitespace state machine. emit_space/emit_break record pending whitespace intent; emit_byte flushes the strongest pending whitespace (break beats space, capped at 2 newlines) then appends one byte to the output buf, honoring HTML2TEXT_MAXOUT. | `/usr/src/lib/libc/stdio/open_memstream.c:1` open_memstream(3) | **KEEP** | M | output accumulation overlaps open_memstream(3)/fugu's own struct buf, but the whitespace-deferral state machine is the actual value here and has no base equivalent | Lead's noted overlap: open_memstream(3) (stdio.h:256, lib/libc/stdio/open_memstream.c) is the base growable-output sink, but fugu already uses its own struct buf (xreallocarray-backed, OOM-fatal) with a hard MAXOUT cap that open_memstream does not give. The space/break collapse logic is bespoke and worth keeping; do not swap the sink for open_memstream (loses the cap and the fatal-OOM guarantee). |
+
+### `src/common/tlsconn.c`
+
+_Thin blocking libtls client shim; every function is a near-1:1 wrapper over base libtls (linkable, -ltls) used idiomatically per the man pages and ftp(1) fetch.c. All ADOPT (already on base); minor alignment nits only._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| tlsconn_client_config | Build a verifying client tls_config; optionally load a CA bundle file into memory via tls_load_file + tls_config_set_ca_mem so the connect path needs no rpath (pledge "stdio inet dns"). | `/usr/src/lib/libtls/tls_util.c:151` tls_load_file(3) | **ADOPT** | S | Already correct base usage. One alignment nit: the man page pairs tls_load_file() with tls_unload_file() (zeroes the buffer) rather than plain free(ca) at lines 46/50. CA data is not secret so impact is cosmetic, but tls_unload_file() is the documented idiomatic counterpart and is a linkable base symbol (declared in /usr/include/tls.h, impl tls_util.c:223). Optional: also consider tls_config_set_protocols() if you want to pin TLSv1.2+1.3 explicitly rather than relying on the libtls default. | **[verified correction: tls_load_file def is :151 (tls_config_* live in tls_config.c)]** tls_load_file/tls_config_set_ca_mem/tls_config_new all confirmed in base man page (man -k) and declared in /usr/include/tls.h (grep -c tls_load_file = 1). Comment at line 44 correctly notes set_ca_mem copies the data, justifying the free of the caller's copy. |
+| tlsconn_connect | Create a tls_client, configure it, tls_connect(host,port) (libtls does its own socket+DNS), then drive the handshake to completion; libtls owns and closes the fd. | `man tls_connect(3)` | **ADOPT** | S | Already correct base usage; the tls_configure->tls_connect*->handshake-loop->tls_error sequence mirrors fetch.c:650-665 exactly. No change needed. | **[verified correction: ftp/fetch.c never calls tls_connect() (uses tls_connect_socket); cite the man page]** tls_connect confirmed in man -k output (tls_connect(3) groups tls_connect_socket etc.). Error reporting via tls_error(ctx) matches fetch.c idiom. Fail path frees ctx and nulls it correctly. |
+| tlsconn_connect_socket | Same as tlsconn_connect but over a caller-supplied connected fd (used when the worker does its own connect under tighter pledge); records fd ownership in c->fd, clears it on failure since caller still owns the passed fd. | `/usr/src/usr.bin/ftp/fetch.c:655` tls_connect_socket(3) | **ADOPT** | S | Already correct base usage; near-identical to fetch.c:655 (tls_connect_socket(tls, fd, sslhost) then the tls_handshake WANT_POLL loop). fd-ownership handling on the fail path (c->fd = -1 so caller keeps the fd) is a correct extension over fetch.c. No change needed. | tls_connect_socket confirmed in man -k and tls_connect_socket(3) man page exists. fetch.c uses servername (sslhost) the same way fugu passes servername. |
+| tlsconn_read | Blocking read wrapper: call tls_read and retry immediately on TLS_WANT_POLLIN/POLLOUT so callers see ordinary blocking-read semantics. | `/usr/src/usr.bin/ftp/fetch.c:1779` tls_read(3) | **ADOPT** | S | Already correct base usage; the do{}while(WANT_POLLIN\|\|WANT_POLLOUT) loop is the exact documented blocking-fd pattern (tls_read(3) RETURN VALUES: "the same function call should be repeated immediately") and is byte-for-byte fetch.c's stdio_tls_read_wrapper (fetch.c:1778-1782). No change needed. | **[verified correction: off-by-one: tls_read call is :1779]** Confirmed against tls_read(3) man page text and fetch.c:1767/1780. Returns -1 on error / size on success straight through, matching man page. |
+| tlsconn_write | Blocking write wrapper: call tls_write and retry immediately on TLS_WANT_POLLIN/POLLOUT. | `/usr/src/usr.bin/ftp/fetch.c:1766` tls_write(3) | **ADOPT** | S | Already correct base usage; matches the tls_write(3) man-page blocking example (continue on WANT_POLL*) and fetch.c's stdio_tls_write_wrapper (fetch.c:1759-1769) line-for-line. No change needed. | **[verified correction: off-by-one: tls_write call is :1766]** Confirmed against tls_write(3) EXAMPLES section and fetch.c:1767. |
+| tlsconn_write_all | Loop tlsconn_write over a buffer until all len bytes are written, since tls_write may return a short count. | — | **KEEP** | S | No base TLS write-all primitive; this is a trivial short-write drain loop over tls_write. The pattern (while len>0: ret=tls_write; buf+=ret; len-=ret) is exactly the tls_write(3) man-page blocking example, so it is idiomatic, just not a library symbol. | Correct: tls_write can short-write so the loop is necessary. Minor robustness note (not base-related): on n==0 it would spin, but tls_write does not return 0 per the man page (size on success, -1 on error, or WANT_POLL* handled inside tlsconn_write), so this cannot loop forever in practice. |
+| tlsconn_close | Tear down: tls_close (TLS-layer shutdown) then tls_free the context, and close the owned fd if any. | `/usr/src/lib/libtls/tls.c:921` tls_close(3) | **ADOPT** | S | Already correct base usage. Aligns with tls_close(3): "Only the TLS layer will be shut down and the caller is responsible for closing the file descriptors, unless the connection was established using tls_connect()." fugu correctly close()s c->fd only for the connect_socket case (fd>=0) and leaves the tls_connect() case (fd==-1) for libtls to close. Minor: tls_close can return TLS_WANT_POLL* / -1, which fugu ignores; acceptable on teardown but could be drained like the other loops for symmetry. | **[verified correction: tls_close def (path_line was empty)]** tls_close(3) is part of the tls_read(3) man page group (verified). fd ownership logic is consistent with the connect/connect_socket fd bookkeeping. |
+| do_handshake | Static helper: drive tls_handshake to completion, retrying on TLS_WANT_POLLIN/POLLOUT (blocking semantics). | `/usr/src/usr.bin/ftp/fetch.c:660` tls_handshake(3) | **ADOPT** | S | Already correct base usage; the do{}while(WANT_POLL*) loop is identical to fetch.c:658-661. Explicit handshake before first read/write is optional per tls_handshake(3) (read/write auto-handshake) but is a clean way to surface handshake errors early. No change needed. | **[verified correction: off-by-one: tls_handshake call is :660]** Confirmed against tls_handshake(3) (in the tls_read(3) group) and fetch.c:658-661. Returns the raw 0/-1 which callers map to errstr via tls_error. |
+
+### `src/common/anthropic.c`
+
+_App-specific Anthropic Messages API request serializer + SSE event interpreter; built entirely on fugu's own buf/json layers. No linkable base equivalent exists (no base JSON/SSE library). The one base seam, strtonum in obj_getint, is already idiomatically adopted. Everything else is KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| anthropic_build_request | Serialise a streaming Messages request body (model, max_tokens, stream, system, messages array, tools) into a struct buf via the json_writer emitter. | — | **KEEP** | S | Anthropic wire-format construction; no base JSON emitter exists (no json header in /usr/include). | Pure glue over fugu's json_writer. The buf-vs-open_memstream question belongs to buf.c/json.c, not here; this function never touches raw stdio. Charter-clean. |
+| anthropic_build_request_raw | Same as build_request but splices pre-formatted messages_json verbatim as the messages value (the net worker's conversation projection). | — | **KEEP** | S | Anthropic-specific body shape; no base equivalent. | Near-duplicate of build_request differing only in messages source; could be internally factored but that is a local refactor, not a base seam. |
+| anthropic_stream_init / anthropic_stream_clear | Lifecycle for struct anthropic_stream: zero the struct, copy callbacks, init/free the three accumulator bufs (tool_id, tool_name, tool_json), set cur_index=-1. | — | **KEEP** | S | Trivial struct init/teardown over fugu buf; nothing in base to adopt. | memset(0)+buf_init/buf_free idiom. Standard C; no libutil/libevent helper applies. |
+| anthropic_stream_event | Top-level SSE-event dispatcher: parse the event's JSON data, validate it is an object, then route by event name (content_block_delta/start/stop, message_start/delta/stop, error; ping/unknown ignored). | — | **KEEP** | M | No base SSE/event-stream parser exists (grep of /usr/src for event-stream/content_block_delta: zero hits). | Strcmp dispatch table over Anthropic's event vocabulary. Entirely protocol-specific; base offers no linkable equivalent and no program-internal one to mirror. |
+| ev_message_start | Handle message_start: read message.usage.{input,output}_tokens and fire on_usage callback. | — | **KEEP** | S | Anthropic usage-block extraction; app-specific. | Navigates fugu json_doc via json_obj_get + obj_getint helper. No base seam. |
+| ev_block_start | Handle content_block_start: record block index, detect tool_use blocks, capture tool id/name into accumulator bufs. | — | **KEEP** | S | Anthropic content-block state machine; app-specific. | obj_getstr -> buf_puts -> free pattern over fugu json. Correctly frees each strdup. No base equivalent. |
+| ev_block_delta | Handle content_block_delta: route text_delta to on_text callback and input_json_delta into the tool_json accumulator (thinking_delta ignored in v1). | — | **KEEP** | S | Anthropic delta-type routing; app-specific. | Streaming fragment dispatch; no base equivalent. json_tok_strdup borrowed from fugu json layer. |
+| ev_block_stop | Handle content_block_stop: if the closing block was a tool_use, emit the assembled tool call (id, name, accumulated JSON, defaulting to {} when empty) via on_tool_call, then reset accumulators. | — | **KEEP** | S | Anthropic tool-call finalisation; app-specific. | Uses bufcstr borrow-trick to NUL-terminate accumulators. No base seam. |
+| ev_message_delta | Handle message_delta: update output_tokens (fire on_usage) and surface delta.stop_reason via on_stop. | — | **KEEP** | S | Anthropic stop/usage extraction; app-specific. | json_tok_type guard before strdup is correct. No base equivalent. |
+| ev_error | Handle error events: extract error.type/message, mark the stream fatally errored, fire on_error with safe fallbacks. | — | **KEEP** | S | Anthropic error-envelope handling; app-specific. | Frees both strdup'd strings; NULL-safe fallbacks. No base seam. |
+| obj_getstr | Helper: duplicate an object member (string or primitive) as a free()-able C string, or NULL if the key is absent. | — | **KEEP** | S | Thin wrapper over fugu json_obj_get/json_tok_strdup; no base JSON accessor exists. | Belongs to the json layer's call-convenience, not a base-replaceable unit. |
+| obj_getint | Helper: read an object member as long long, parsing the token text with strtonum() and falling back to a default on absence or parse error. | `/usr/src/usr.bin/calendar/io.c` strtonum(3) | **KEEP** | S | Already adopts the correct base API; no further base alignment available. | Verified strtonum is base: declared /usr/include/stdlib.h:305, man strtonum(3), idiomatic across /usr/src/usr.bin (aucat/audioctl/cal/calendar). The function already uses strtonum(LLONG_MIN,LLONG_MAX,&errstr) with the canonical errstr-checked pattern, so this is best-practice already, not a pending ADOPT. Listed KEEP because the only base seam is already taken. |
+| bufcstr | Helper: borrow a struct buf's bytes as a NUL-terminated C string by appending '\0' then uncounting it (len--), so the data pointer is a valid cstr without growing len. | — | **KEEP** | S | buf-internal idiom; depends entirely on fugu's struct buf, not a base type. | If buf.c were ever reimplemented on open_memstream(3) (stdio.h, confirmed in base), this borrow-trick would become a simple fflush+pointer read — but that is a buf.c-layer decision, out of scope for this file. Here it stays KEEP. |
+
+### `src/parent/parent.c`
+
+_Privsep coordinator is already well-aligned to base idioms (imsgbuf_*, freezero, closefrom). One clean ADOPT (scandir for the 3 hand-rolled dir scans); the spawn/relay core is a strong REFERENCE to relayd/proc.c; the poll loop vs libevent is a deliberate-design REFERENCE, not a must-adopt given fugu's foreground/non-daemon charter._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| usage | Print usage string to stderr and exit(1). | — | **KEEP** | S | trivial per-program boilerplate; no library equivalent | Standard OpenBSD usage() pattern already (extern __progname, fprintf, exit). Nothing to adopt. |
+| main | getopt parse, conf_load, dispatch -n/-l/-r/-c, then run() the privsep coordinator. | getopt(3) | **KEEP** | S | already uses base getopt(3) idiomatically | Option handling is textbook base getopt. argc/optind cleanup correct. No change. |
+| create_session_dir | Best-effort mkdir of ~/.fugu and ~/.fugu/sessions (0700). | — | **KEEP** | S | plain mkdir(2); no base mkdir -p library helper exists | Base has no libc 'mkpath'/mkdir-p; mkpath(3) is libutil-adjacent but not present here. Two literal mkdir calls are fine and idiomatic. |
+| sessions_path | Compose "$HOME/.fugu/sessions" with overflow check. | — | **KEEP** | S | snprintf-with-truncation-check is the base idiom already | Correct (>= size) truncation guard. No win available. |
+| session_exists | Validate id has no '/'/'.'/'..' then stat ~/.fugu/sessions/<id>.jsonl as a regular file. | — | **KEEP** | S | path-traversal guard + stat is application policy, not a library seam | The strchr('/')/dot checks are a deliberate security guard; no base helper replaces it. Keep. |
+| resolve_last / list_sessions (directory scan group) | Enumerate ~/.fugu/sessions/*.jsonl: resolve_last picks newest by mtime; list_sessions builds rows and prints. Both hand-roll opendir+readdir(+qsort in list). | `/usr/src/usr.bin/diff/diffdir.c:82` scandir(3) | **ADOPT** | M | scandir mallocs the whole namelist up front and you supply a select() filter + comparator; for resolve_last you still walk+stat each entry so the win is mostly the readdir/closedir boilerplate, not the mtime logic. The qsort in list_sessions stays (mtime sort, not name sort, so alphasort doesn't replace sessrow_cmp). | scandir confirmed declared /usr/include/dirent.h:91 and man scandir(3); real base caller /usr/src/usr.bin/diff/diffdir.c:82 (scandir(path1,&dirp1,selectfile,alphasort)). ADOPT collapses the opendir/while(readdir)/closedir trio into one call with a select() that matches the ".jsonl" suffix. Note: the mtime-newest sort is fugu-specific, so keep sessrow_cmp + qsort; scandir replaces the iteration, not the ordering. |
+| session_preview | Parse a stored JSON message, extract "content" string, fold control chars to space, collapse whitespace into a one-line preview. | — | **KEEP** | S | JSON + control-char sanitization is app logic over vendored jsmn; no base option | vis(3)/strnvis exist in base for visual encoding but the goal here is space-folding for a fixed-width column, not vis escaping. Keep. |
+| scan_session | Count non-empty lines (messages) in a .jsonl file and preview the first via getline. | getline(3) | **KEEP** | S | already uses base getline(3); newline trim is inherent, no one-call variant | getline confirmed (GETDELIM(3)). The manual \n/\r trim loop is the standard idiom; base offers no trimming reader. Keep. |
+| sessrow_cmp | qsort comparator ordering sessions newest-mtime-first. | qsort(3) | **KEEP** | S | mtime comparator can't be alphasort(3); it's domain ordering | alphasort sorts by name; this sorts by mtime descending, so it must stay hand-written. Correct (avoids subtraction overflow by branching). Keep. |
+| run | Spawn all 4 workers with policy argv, delegate config+secrets, pledge "stdio proc", run relay, shutdown. | `/usr/src/usr.sbin/relayd/proc.c:180` | **REFERENCE** | L | relayd's proc_init/proc_setup is a full privsep framework (struct privsep, instance arrays, IMSG_CTL_PROCFD fd-passing). Adopting it wholesale is heavy and libevent-coupled; fugu's flat 4-worker model is simpler and intentionally foreground. Mirror the ordering/teardown discipline, don't link. | proc_init at /usr/src/usr.sbin/relayd/proc.c:180 is the architectural analogue (setup pipes -> exec children -> connect). REFERENCE only: it's program-internal (usr.sbin), not a linkable lib, and pulls in libevent + the privsep struct model that fugu deliberately avoids. |
+| spawn | socketpair + fork; in child dup2 the pair onto fd 3, redirect non-ui stdio to /dev/null, closefrom, execv; in parent ip_init the channel. | `/usr/src/usr.sbin/relayd/proc.c:70` | **REFERENCE** | M | proc_exec at proc.c:70 is the canonical base pattern: dup2 the pipe onto a fixed PROC_PARENT_SOCK_FILENO then execvp. fugu mirrors it (fd 3 = IMSG_CHILD_FD). It's not linkable, so REFERENCE: align edge-case handling (fd==target fcntl(F_SETFD,0) vs dup2) but keep fugu's own closefrom(3)/dev-null hardening, which is already strong. | closefrom(2) confirmed /usr/include/unistd.h:491 and already used. fugu's spawn is arguably tighter than proc_exec (closefrom(IMSG_CHILD_FD+1) drops every stray fd; proc_exec relies on F_SETFD discipline). Mirror, don't replace. |
+| delegate_net / delegate_exec / delegate_fetch (config+secret delegation group) | Build a JSON config object per worker and ip_compose+ip_flush M_CONFIG, then resolve and send M_SECRET, freezero-ing the secret copy. | imsg_compose(3) | **KEEP** | S | the imsg send path is already base (imsgproto wraps imsg_compose/imsgbuf_flush); JSON-config building is app logic | Secret hygiene already correct: freezero(key,strlen) confirmed in malloc(3); secrets travel over imsg payload, never argv/env (charter-compliant). No base helper builds the JSON config. Keep; this is the secrets-never-in-argv design working as intended. |
+| resolve_secret | Return an inline secret (xstrdup) or read+trim the first line of a key file via getline. | getline(3) | **KEEP** | S | getline + trim is the base idiom; no secret-file reader in base | Reading a credential file's first line is app policy. getline already used. Caller freezero's the result. Keep. |
+| worker_by_ep | Map an endpoint id to its active worker slot (linear scan of 4). | — | **KEEP** | S | 4-element routing table lookup; no library seam | Trivial routing helper over fugu's own WDESC table. Nothing in base applies. |
+| relay_loop | poll() over all active worker fds; on POLLIN drain frames, on POLLHUP/ERR exit. The coordinator's central event loop. | `/usr/src/sbin/dhcpleased/frontend.c:167` event(3) | **REFERENCE** | L | libevent IS base (/usr/lib/libevent.so, event(3)) and dhcpleased/frontend.c:167-190 shows the canonical event_init()+per-channel event_set/event_add+event_dispatch() design. But for a fixed set of 4 channels with no timers/signals beyond SIGPIPE, a flat poll() loop is simpler, has no callback indirection, and keeps the coordinator foreground/non-daemon (fugu's charter). RECOMMEND evaluating libevent only if timers/backpressure/more channels arrive; do NOT adopt now. | Honest tradeoff: this is the big design question. event_dispatch confirmed in event(3) and libevent.a/.so present in base. dhcpleased frontend(): event_init() (frontend.c:167), event_set/event_add per imsg channel (185-187), event_dispatch() (190). The win would be write-side backpressure (imsgbuf_queuelen -> EV_WRITE, see relayd proc.c imsg_event_add) which fugu's poll loop currently doesn't arm (it only POLLINs; relies on ip_flush blocking). That's the one real gap worth noting — but addressable in the poll model too. REFERENCE, recommend-not-adopt. |
+| relay_drain | imsgbuf_read one worker, loop ip_frame_get pulling raw frames, route each by imsg_get_id to the destination worker via ip_forward+ip_flush, never reassembling payloads. | `/usr/src/usr.sbin/relayd/proc.c:593` imsg_forward(3) | **REFERENCE** | M | fugu already uses the exact modern base primitives (imsgbuf_read, imsg_get, imsg_get_id, imsg_forward, imsg_free — all confirmed in /usr/include/imsg.h via the imsgproto wrapper). proc_dispatch (proc.c:593) is the structural twin: imsgbuf_read -> for(;;) imsg_get -> handle/forward -> imsg_free, then imsg_event_add. Can't link it (program-internal + libevent-bound), so REFERENCE: mirror its EPIPE/n==0 channel-death handling. | imsg_forward confirmed /usr/include/imsg.h:162, imsg_get_id at :158, imsgbuf_read at :144 — fugu is already on the post-rewrite imsgbuf_* API (the rewrite the lead warned about), so no migration debt. The relay's forward-by-id-without-reassembly is precisely what imsg_forward(3) is for and what relayd/proc_forward_imsg does. Strongest alignment point: copy proc_dispatch's death/EPIPE branch semantics. |
+| shutdown_workers | Close each worker channel (EOF -> child exits), clear imsg state, then waitpid each pid. | `/usr/src/usr.sbin/relayd/proc.c:351` waitpid(2) | **REFERENCE** | S | proc_kill (proc.c:351) does the analogous close-pipes-then-reap; fugu's two-pass (close all, then wait all) is correct and arguably cleaner. No linkable helper; align the reaping discipline only. | waitpid(2) base. fugu's ordering (EOF every child first so they all exit concurrently, then reap) avoids serial-shutdown stalls. Keep the implementation; REFERENCE proc_kill purely for the close->SIGsomething->wait convention if a forced-kill timeout is ever wanted (fugu currently relies on EOF, which is fine for cooperative workers). |
+
+### `src/net/net.c, src/net/net_run.c`
+
+_net.c is a textbook base-idiomatic worker main (getopt/log/pledge/fstat-S_ISSOCK) — fully aligned, nothing to ADOPT beyond what it already uses; net_run.c is app-specific agent-loop/imsg-dispatch/blocking-HTTPS logic that is mostly KEEP, with the imsg plumbing and synchronous TLS HTTP client being REFERENCE alignments to base imsgbuf_* and ftp(1)'s url_get/tls_read blocking client._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main (net.c) | Worker entry: getopt(d/v), log_init/log_setverbose, ignore SIGPIPE, fstat the inherited fd and require S_ISSOCK, ip_init the imsg channel, run net_loop. | `/usr/src/usr.sbin/httpd/httpd.c (getopt main idiom); /usr/include/sys/stat.h:140 (S_ISSOCK)` getopt(3) | **REFERENCE** | S | Already mirrors base worker-main conventions verbatim (getopt loop, SIG_IGN on SIGPIPE per ssh mux.c:1991, log_init/fatal from the copied base log.c, S_ISSOCK guard); nothing to change, keep aligned. SIG_IGN-on-SIGPIPE matches base; could use ssh_signal-style wrapper but that is ports/ssh-internal, not warranted. | All primitives (getopt, signal, fstat, S_ISSOCK, fprintf, __progname) are pure libc/base. The only non-libc calls are fugu's own log_*/ip_init wrappers, which themselves wrap base log.c and base imsg. This main is the idiomatic OpenBSD privsep-worker pattern; no ADOPT gap. |
+| net_loop | The imsg event loop: emit M_READY, then ip_get/ip_read-drain dispatch on M_SECRET/M_CONFIG/M_SUBMIT/M_SUMMARIZE/M_SHUTDOWN; stores the API key via xmalloc/freezero; narrows pledge to 'stdio rpath inet dns' on entry. | `/usr/src/lib/libutil/imsg.c; /usr/src/lib/libutil/imsg_init.3` imsg_init(3) | **REFERENCE** | M | The read-drain-then-dispatch shape is the canonical imsg consumer loop; fugu reimplements it behind ip_get/ip_read which wrap the current imsgbuf_* API (imsgbuf_init/imsgbuf_read/imsg_get) — confirmed current via imsg_init(3) listing imsgbuf_init/imsgbuf_read/imsgbuf_flush. Align idioms to base; the dispatch switch itself is app-specific (KEEP-flavoured). | Verified base now exposes imsgbuf_init/imsgbuf_read/imsgbuf_flush/imsg_get (the post-rewrite API) in imsg_init(3) and /usr/include/imsg.h. The actual imsgbuf_* adoption lives in fugu's ip_* wrapper (imsgproto.c, not in these files); these files only consume that wrapper. pledge/freezero are pure base. Loop body (secret handling, turn/summarize routing) is application logic = KEEP, but the plumbing is a base-aligned REFERENCE. |
+| net_run_turn | The agent loop: repeatedly call the Messages API; while the model requests tools, build the assistant message, run tools, append tool_results, and re-call, up to NET_MAX_ITERS (50). | — | **KEEP** | L | Anthropic agent/tool-use orchestration; no base analogue. | Pure application control flow over fugu's own buf/json/imsg helpers. The synchronous structure (block on each API call, block on each tool round) is the deliberate design; base has nothing comparable. Only the inner net_api_call has a base REFERENCE. |
+| net_summarize | /compact: append a closing user turn, run one tool-less Messages call with the summary system prompt (deltas hushed), emit collected text as M_SUMMARY. | — | **KEEP** | M | Anthropic-specific summarisation call; no base equivalent. | Application logic; uses fugu json_writer + net_api_call. No base seam. |
+| net_api_call | One streamed Messages call: build JSON body + HTTP request, tlsconn_connect, write_all the request, then loop tlsconn_read into a 4KB buffer feeding http_resp_push/eof, capture non-200 error body, classify status. | `/usr/src/usr.bin/ftp/fetch.c:306 (url_get); /usr/src/usr.bin/ftp/fetch.c:1773 (stdio_tls_read_wrapper)` tls_connect(3) | **REFERENCE** | M | libtls itself is base (-ltls, tls.h) and IS used via fugu's tlsconn wrapper; but the request-build / status-parse / blocking-read-loop is solved only inside ftp(1) (usr.bin), not a linkable library, so REFERENCE. Mirror ftp(1)'s blocking idiom: retry tls_read on TLS_WANT_POLLIN/POLLOUT (fetch.c:1773-1782) rather than treating a short/again read as fatal, and its status-line/header parsing in url_get. | Confirmed ftp(1) does exactly fugu's pattern: tls_connect_socket then a do/while tls_read loop that retries on TLS_WANT_POLLIN/TLS_WANT_POLLOUT (stdio_tls_read_wrapper, fetch.c:1773), wrapped through funopen at fetch.c:666; url_get (fetch.c:306) builds the request and parses the response status. fugu's tlsconn_read presumably already handles TLS_WANT; the alignment value is in the edge-case/status-parsing handling. No linkable HTTP client in base = not ADOPT. |
+| conv_append | Append one JSON message object to a messages array that ends in ']' (drop ']' , add ',' + object + ']'). | — | **KEEP** | S | Trivial JSON-array splice on fugu's buf; no base API. | Manipulates fugu buf directly. No base JSON facility (jsmn is parse-only and vendored). |
+| build_assistant_msg / build_tool_results (assistant+tool_result JSON builders) | Serialize the collected turn into Anthropic message JSON: an assistant message (text + tool_use blocks) and a user message of tool_result blocks (the latter runs each tool and embeds its content/is_error). | — | **KEEP** | M | Anthropic message-shape serialization via fugu json_writer; no base JSON writer. | Cohesive pair that builds the two conversation-turn JSON objects. Pure application logic; base has no JSON-emitter library (jsmn is read-only). |
+| send_tool_and_wait | Compose+flush an M_TOOL_REQUEST to the tool worker, then block ip_get/ip_read until the matching M_TOOL_RESULT (or M_SHUTDOWN), parse content/is_error, ignoring other messages mid-turn. | `/usr/src/lib/libutil/imsg.c` imsg_init(3) | **REFERENCE** | M | The compose/flush then read-until-reply pattern is the base imsg RPC idiom (imsg_compose + imsgbuf_flush + imsgbuf_read/imsg_get). fugu wraps it as ip_compose/ip_flush/ip_get/ip_read; align to current imsgbuf_* naming and MAX_IMSGSIZE bounds. The synchronous-block-mid-turn policy is app-specific. | Uses fugu ip_* wrappers (which sit on base imsgbuf_*). Verified base API names current in imsg_init(3). Not ADOPT because the wrapper and the blocking-RPC policy are fugu's; the underlying transport is the base imsg adoption already in the ip_* layer. |
+| tool_dst | Route a tool name to its worker endpoint: web_search/web_fetch -> EP_FETCH, else EP_EXEC. | — | **KEEP** | S | Two strcmp dispatch; pure fugu routing policy. | Trivial; no base seam. |
+| turncollect_free | Free a turncollect: buf_free text, free each toolcall id/name/input, free arrays and stop_reason. | — | **KEEP** | S | Struct destructor over fugu allocations; no base API. | Standard free helper. free(3) is libc but there is no base 'destructor' to adopt. |
+| net_emit / net_emit_error | Emit an imsg (ip_compose+ip_flush, fatalx on failure); net_emit_error formats 'what: detail' into a buf and emits M_ERROR to the UI. | `/usr/src/lib/libutil/imsg.c` imsg_init(3) | **REFERENCE** | S | Thin convenience over the base imsg send path (compose+flush). Keep, but ensure the wrapped calls track current imsgbuf_flush semantics. net_emit_error formatting is app-specific. | Wraps fugu ip_* over base imsgbuf_*. fatalx on send failure is the base daemon convention (log.c). |
+| net_apply_config / cfg_str | Parse a JSON config object (json_parse) and update model/max_tokens/system/host/port/cafile; max_tokens via strtonum(1,1000000); cfg_str fetches a JSON string field. | strtonum(3) | **ADOPT** | S | Already correctly uses strtonum(3) (base, <stdlib.h>) with min/max + errstr for max_tokens — the idiomatic OpenBSD safe-integer parse; no change needed, this is the win already taken. The JSON field extraction (json_obj_get/json_tok_strdup) is fugu/jsmn and stays KEEP. | strtonum(3) confirmed in base (man 3 strtonum, declared in <stdlib.h>). This is a genuine base-API adoption fugu already does correctly. The surrounding JSON config plumbing has no base equivalent (jsmn vendored). |
+| net_ensure_ready | One-time readiness: load the TLS client config from cafile (tlsconn_client_config), then narrow pledge to 'stdio inet dns' and set ready. | tls_connect(3) | **REFERENCE** | S | The privilege-narrowing-after-setup (open CA bundle under rpath, then drop rpath) is the canonical pledge staging idiom. CA load uses libtls (base) behind tlsconn_client_config. Keep; align CA-load error handling to libtls conventions. | pledge(2) and libtls are base. The staged pledge narrowing matches base daemons. tlsconn_client_config is fugu's wrapper over base tls_config_*; the adoption already happened in the tlsconn layer (not these files). |
+| net_state_clear | Teardown: freezero the key, free model/system/host/port/cafile, tls_config_free, zero the struct. | freezero(3) | **ADOPT** | S | Correctly uses freezero(3) (base, malloc(3) family) to scrub the secret key and tls_config_free(3) (base libtls) — both idiomatic; no change needed. Honors the charter's 'secrets never linger' intent. | freezero(3) confirmed in MALLOC(3) listing; tls_config_free is base libtls. Both are real base symbols already adopted. The rest is plain free(3). |
+| net_body | HTTP body sink trampoline: on status 200 feed bytes to the SSE parser, else accumulate into the error buf. | — | **KEEP** | S | Callback trampoline between fugu http_resp and sse parser; no base API. | Pure fugu pipeline glue. |
+| sse_to_anthropic | SSE-event trampoline: forward (event,data,len) to anthropic_stream_event. | — | **KEEP** | S | One-line forwarder between fugu sse and anthropic parsers; no base seam. | Server-Sent-Events is not a base facility; vendored/own parser. KEEP. |
+| anthropic stream callbacks (on_text/on_tool_call/on_usage/on_stop/on_err/on_done) | Six libcb callbacks that relay streamed events to the UI (M_DELTA/M_TOOL_CALL/M_USAGE/M_ERROR) and collect them into the active turncollect; on_done is a no-op. | — | **KEEP** | M | Anthropic streaming event handlers; no base analogue. | Cohesive callback set wired into NET_CBS. They emit imsg via net_emit (base-imsg REFERENCE already covered there) but the event semantics are entirely Anthropic-specific. KEEP. |
+
+### `src/exec/exec.c, src/exec/exec_run.c`
+
+_Thin worker entry + cwd-confined sandbox; the unveil/pledge/execpromises sandbox is an idiomatic base privsep pattern (REFERENCE), getopt/signal are libc (ADOPT, already used), the rest is app-specific imsg/tool/json plumbing (KEEP)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main | fugu-exec worker entry: getopt("dvN"), log_init/setverbose, ignore SIGPIPE, fstat the imsg fd 3 to refuse direct invocation, then ip_init + exec_sandbox + exec_loop. | getopt(3) | **ADOPT** | S | Already on the base idiom; nothing to change. | Pure base-idiom entry: getopt(3) option loop, signal(SIGPIPE,SIG_IGN) (cf /usr/src/usr.bin/ftp/ftp.c), fstat(2)+S_ISSOCK guard, __progname libc extern. The only non-base calls (log_init, ip_init) are fugu-internal helpers and stay KEEP by composition, but main() itself is textbook libc. Verified signal/SIGPIPE idiom present in base (ftp.c, dig). |
+| exec_sandbox | Builds the cwd-confined no-network sandbox: unveil(".","rwc"), hide ~/.fugu, unveil base tool/lib dirs rx, optional resolver/trust store for opt-in subprocess net, unveil(NULL,NULL) lock, then two-arg pledge(promises, execpromises) where execpromises is the ceiling inherited across execve(2) for ksh/grep/find/ls children — inet-excluding unless allow_subprocess_net. | `/usr/src/libexec/ld.so/ldd/ldd.c:186` pledge(2), unveil(2) | **REFERENCE** | S | No base library wrapper to link — pledge/unveil are raw syscalls; this is align-the-idiom, not adopt-a-symbol. | Confirmed already idiomatic. The exec->execpromises pattern (tighten a child's pledge ceiling before/around execve) is exactly ldd's pledge("stdio rpath exec","stdio rpath") at /usr/src/libexec/ld.so/ldd/ldd.c:186, verified by reading lines 185-186. Two-arg pledge(promises,execpromises) confirmed in pledge(2) synopsis (man line 10) and behavior at man lines 218/286. unveil-then-lock(NULL,NULL) before the final pledge matches unveil(2)'s 'strongly recommended to lock' guidance. Lead's 'sandbox idiom is REFERENCE' confirmed correct; structure is sound. |
+| unveil_or_skip | Helper: unveil(path,perms) tolerating ENOENT so optional dirs (/sbin, /usr/local/lib, resolver files) need not exist. | `/usr/src/sbin/sysctl/sysctl.c:270` unveil(2) | **REFERENCE** | S | No linkable helper in base — every program open-codes the same 2-line guard. | Byte-for-byte the base idiom: /usr/src/sbin/sysctl/sysctl.c:270 does `if (unveil(_PATH_DEVDB,"r")==-1 && errno!=ENOENT)` (also lines 272, 275), verified by grep. fugu's wrapper is the correct factoring of that pattern; nothing to adopt, just confirms idiomatic alignment. |
+| exec_loop | imsg receive loop: ip_get/ip_read drain, dispatch M_CONFIG->apply_config, M_TOOL_REQUEST->handle_tool, M_SHUTDOWN->exit; frees each msg. | — | **KEEP** | S | app-specific dispatch over fugu's imsgproto wrapper and fugu_msg taxonomy. | Underlying transport already rides base imsg via fugu's imsgproto, but the loop control flow and message types (M_CONFIG/M_TOOL_REQUEST/M_SHUTDOWN) are fugu's. No base equivalent for the dispatch itself. |
+| apply_config | Parse M_CONFIG JSON object and read allow_write bool into execstate. | — | **KEEP** | S | app-specific config schema via fugu's vendored jsmn wrapper; no base JSON in charter scope. | Trivial schema-specific parse over json_doc; out of scope for base adoption (jsmn is the only allowed vendored dep). |
+| handle_tool | Dispatch M_TOOL_REQUEST by tool name (read/write/edit/shell/grep/find/ls) into tool_* implementations, marshal missing-arg errors, then emit_result. | — | **KEEP** | M | core app tool registry; no base equivalent. | Lead confirms tool dispatch is app-specific KEEP. The strcmp-chain could be a dispatch table but that's a style nit, not a base-adoption opportunity. Arg extraction via obj_getstr, error strings via buf. |
+| emit_result | Build M_TOOL_RESULT JSON {id,content,is_error} via fugu's json_writer and send it with ip_compose/ip_flush to EP_NET. | — | **KEEP** | S | app-specific result framing over fugu's JSON writer and imsg wrapper. | No base option: JSON output is fugu's own writer; imsg compose/flush is fugu's imsgproto over base imsgbuf_*. |
+| obj_getstr | Convenience accessor: fetch a JSON string value by key from an object token, strdup'd, else NULL. | — | **KEEP** | S | thin accessor over fugu's vendored jsmn json_doc; no base JSON library in scope. | Pure jsmn-wrapper helper (json_obj_get + json_tok_type + json_tok_strdup); nothing in base to align to. |
+
+### `src/exec/tools_file.c, src/exec/tools_shell.c`
+
+_Both files already lean on base libc primitives (mkstemp, memmem, closefrom) correctly; the composite helpers (atomic-write, file-slurp, fork+exec+poll capture) have no LINKABLE base equivalent — their closest base analogues are program-internal (acme-client/sed atomic-write, ssh subprocess capture), so they are REFERENCE/KEEP, not ADOPT._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| tool_read | Public entry: slurp a file into out, formatting an errno message on failure. | — | **KEEP** | S | no base option: app-specific buf+errno-message glue, no linkable libc equivalent for 'read whole file into growable buf' | Just dispatches to static slurp() and renders strerror into the struct buf. Nothing for base to replace; the real reuse opportunity lives in slurp(). |
+| tool_write | Public entry: allow_write policy gate, then durable atomic write of content to path. | — | **KEEP** | S | no base option: the allow_write authorization gate and message are fugu policy; the durable-write mechanics are in write_atomic() | Charter-relevant guard (allow_write no). Base has no equivalent for the policy decision; mechanics evaluated under write_atomic. |
+| tool_edit | Read file, locate a unique old_string, splice in new_string, atomic-write the result. | `/usr/include/string.h:122` memmem(3) | **ADOPT** | S | Already adopted: the load-bearing search uses base memmem(3) (libc), declared at string.h:122, confirmed binary-safe via big_len; the surrounding splice is app logic. | memmem(3) is genuine base libc (man 3 memmem; string.h:122). fugu already calls it correctly for both find and the second-occurrence uniqueness probe (m+1, content.len-off-1). The splice/orchestration itself stays fugu's; no further base API applies. Marked ADOPT because the one library-replaceable seam (substring search) is already on the base API and should stay there rather than hand-rolling. |
+| write_all | Write a full buffer to an fd, looping on partial writes and retrying EINTR. | `/usr/src/usr.bin/ssh/atomicio.c:50` | **REFERENCE** | S | ssh's atomicio()/atomiciov() is the canonical full-write loop but lives in ssh's tree, not libc/libutil; grep of /usr/include shows no atomicio declaration, so it is not linkable. | Verified: 'grep atomicio /usr/include' returns nothing — atomicio is per-program in usr.bin/ssh, so KEEP-by-link but mirror its EINTR/EAGAIN handling. fugu's loop already matches the EINTR-retry + partial-write accounting; the only gap vs atomicio is it does not treat a 0-byte write as an error. Low value to change; cite ssh/atomicio.c as the idiom of record. |
+| slurp | Read an entire file into a growable buf with EISDIR/EFBIG guards and a 1 MiB cap. | `/usr/src/usr.bin/ssh/sshbuf-io.c` | **KEEP** | S | no base option: base has no libc/libutil 'load entire file into buffer' call; grep of /usr/include finds no read_file/load_file, and sshbuf_load_file is ssh-internal | Confirmed no linkable equivalent (no read_file/load_file in /usr/include; sshbuf_load_file only under usr.bin/ssh). The S_ISDIR->EISDIR pre-check and TOOLS_MAXFILE cap are fugu-specific safety. open/fstat/read-loop is the right base primitive set already. Keep as-is. |
+| write_atomic | Durable atomic file replace: mkstemp sibling temp, write, preserve mode via fchmod, fsync, rename; unlink temp on any failure. | `/usr/src/usr.sbin/acme-client/fileproc.c:59` mkstemp(3) | **REFERENCE** | M | The base atomic-write pattern (mkstemp -> fchmod -> write -> close -> rename, unlink-on-fail) exists only inside programs (acme-client serialise(), sed, cron crontab.c), not as a linkable libc helper; fugu already uses the base mkstemp(3) primitive correctly. | Canonical base site: acme-client/fileproc.c serialise() at fileproc.c:59 (mkstemp), fchmod fileproc.c:63, rename fileproc.c:77, unlink-on-fail fileproc.c:87 — but NO fsync. sed/main.c:405 preserves mode via 'fchmod(fd, sb.st_mode & ALLPERMS)', matching fugu's st_mode & 0777 preservation; cron/crontab.c:398/525 same shape. fugu IMPROVES on all of them by adding fsync(2) before rename for crash durability — keep that. Action: align edge cases to acme-client (it is the idiom of record) but do not regress fsync. mkstemp(3) itself is confirmed base libc (stdlib.h). |
+| run_capture | Fork a child with stdin=/dev/null and stdout/stderr to a pipe, closefrom() to drop the imsg fd, execv, poll a CLOCK_MONOTONIC deadline, cap captured output, SIGKILL on timeout/overflow, reap with EINTR-safe waitpid. | `/usr/src/usr.bin/ssh/misc.c:2786` closefrom(2) | **REFERENCE** | L | ssh/misc.c subprocess() is a near-identical pipe+fork+dup2(/dev/null)+closefrom(STDERR_FILENO+1)+execv capture, but it is an ssh-internal helper (not libutil) and stops at returning the pipe fd — it has no timeout or output cap, both of which fugu adds. popen(3) is unusable (no stderr capture, no timeout). So overall KEEP-by-link, REFERENCE-by-idiom. | Strong analogue verified: ssh/misc.c:2786 pipe(p); 2796 fork(); 2823 dup2(devnull,STDIN); 2834 dup2(fd,STDOUT); 2838 closefrom(STDERR_FILENO+1); 2863 execve/execv; parent waitpid EINTR loop ~2881 — mirrors fugu line-for-line including the closefrom(STDERR_FILENO+1) to shed inherited fds (fugu's comment: drop imsg fd). closefrom(2) is genuine base (unistd.h:491). The CLOCK_MONOTONIC poll-deadline is the ssh monotime()/ptimeout idiom (misc.c:1735, 341). Differences worth keeping in fugu: wall-clock timeout via monotonic deadline, TOOLS_MAXOUTPUT cap with SIGKILL backpressure, truncation/timeout annotations. Align error/EINTR handling to ssh; do not adopt popen. |
+| tool_shell / tool_grep / tool_find / tool_ls | Thin public wrappers that build a fixed argv (ksh -c, grep -RnI, find, ls -la) and delegate to run_capture. | — | **KEEP** | S | no base option: these are fixed-argv constructors selecting base programs (/bin/ksh, /usr/bin/grep, /usr/bin/find, /bin/ls); there is no linkable library form, and re-implementing grep/find/ls in-process would violate simplicity and the sandbox model | Tight cohesive group of 4 trivial argv-builders over run_capture. Correct design: shell out to base tools and inherit the worker's unveil/pledge sandbox rather than re-implement. The (char*)(uintptr_t) casts launder const into the execv char*const[] contract — harmless. Nothing for base to provide here; the only shared mechanism (capture) is covered by run_capture above. |
+
+### `src/fetch/fetch.c, src/fetch/fetch_run.c, src/fetch/kagi.c`
+
+_Fetch worker: app-specific imsg/tool plumbing (KEEP) over base libtls (tls(3) already ADOPTED via direct calls); the connect+SSRF dial loop and TLS handshake loop mirror ftp(1)'s program-internal fetch.c (REFERENCE); getaddrinfo(3) usage is correct libc; addr_blocked SSRF guard and Kagi formatting have no base equivalent (KEEP)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main | fugu-fetch worker entry: getopt, log_init, SIGPIPE ignore, fstat-asserts imsg fd 3 is a socket, ip_init, runs fetch_loop. | getopt(3) | **KEEP** | S | App-specific worker bootstrap; already built from base primitives (getopt/signal/fstat) with nothing to swap. | The fd-3-is-a-socket guard via fstat+S_ISSOCK is idiomatic OpenBSD; no helper to adopt. |
+| fetch_loop | Main imsg event loop: pledge('stdio rpath inet dns'), send M_READY, dispatch M_SECRET/M_CONFIG/M_TOOL_REQUEST/M_SHUTDOWN, freezero the Kagi token. | pledge(2) | **KEEP** | S | App-specific brokered-tool loop over fugu's imsg layer; pledge is already used correctly. | Secret handling (freezero on token, never argv/env) matches charter. Nothing linkable to adopt. |
+| apply_config | Parse M_CONFIG JSON, override cafile/kagi_host/kagi_port/allow_private. | — | **KEEP** | S | App config over fugu's json wrapper; no base JSON library by charter. | Pure application glue. |
+| ensure_ready | Lazily build TLS client config from cafile via tlsconn_client_config, then narrow pledge to 'stdio inet dns'. | tls_load_file(3) | **KEEP** | S | Already sits on base libtls via fugu's tlsconn wrapper (out of this file's scope); the privilege-narrowing pledge step is app-specific. | Confirmed tls_config_set_ca_file is base (TLS_LOAD_FILE(3)). The wrapper itself lives in tlsconn.c, not audited here; this function is just the call+pledge orchestration => KEEP. |
+| handle_tool | Parse M_TOOL_REQUEST JSON (id/name/input), dispatch web_search vs web_fetch, emit M_TOOL_RESULT. | — | **KEEP** | S | App-specific tool router over fugu json/imsg; no base analog. | Application dispatch logic. |
+| do_web_search | Build Kagi search path+auth header, call http_fetch (guard off for trusted Kagi host), format results; freezero the auth header holding the token. | — | **KEEP** | S | Kagi-API-specific request orchestration; no base equivalent. | Good: token-bearing auth value is freezero'd after use, consistent with charter. |
+| do_web_fetch | Parse URL, enforce https-only, http_fetch with SSRF guard (unless allow_private), reject redirects, branch on content-type to html2text/text/binary with caps. | — | **KEEP** | M | App-specific fetch policy (no-redirect, content-type handling, size caps); no base helper. | strcasestr/strncasecmp are libc and used correctly. The policy (no redirect follow to avoid re-guarding) is deliberate and not something base offers. |
+| ms_left | Compute milliseconds remaining to a CLOCK_MONOTONIC deadline, clamped to [0, FETCH_TIMEOUT*1000]. | `/usr/src/usr.bin/ftp/util.c:1081` clock_gettime(2) | **KEEP** | S | Tiny deadline-math helper; clock_gettime already base, no library function computes a clamped ms remainder. | Could optionally use timespecsub(3) from <sys/time.h> for the subtraction, but the manual arithmetic is fine and idiomatic; not worth a finding upgrade. |
+| io_wait | poll() a single fd for events until the deadline; 1 ready / 0 timeout / -1 error. | poll(2) | **KEEP** | S | Thin single-fd poll wrapper around base poll(2); nothing to adopt. | libevent is base and allowed, but a blocking deadline-bounded worker doing one request at a time correctly uses poll directly; event-loop migration would be overkill here. |
+| addr_blocked | SSRF guard: classify a resolved sockaddr as loopback/RFC1918/link-local/CGNAT/multicast/ULA/v4-mapped and refuse non-public addresses. | — | **KEEP** | M | No base option: confirmed no private-range/SSRF helper in /usr/src/lib/libc (grep empty); base ftp(1) has no such guard at all. | Uses base IN6_IS_ADDR_LOOPBACK/LINKLOCAL/MULTICAST/UNSPECIFIED/V4MAPPED macros correctly. The IPv4 octet checks and fc00::/7 ULA + CGNAT (100.64/10) handling are hand-rolled because base provides no equivalent. This is the security core and must stay in fugu. |
+| dial | getaddrinfo(host,port) then iterate results: SSRF-guard each resolved addr, non-blocking connect with EINPROGRESS+io_wait deadline, return connected fd. | `/usr/src/usr.bin/ftp/fetch.c:599` getaddrinfo(3) | **REFERENCE** | M | getaddrinfo itself is correct libc usage (cf. /usr/src/usr.bin/ftp/fetch.c:531); the connect-with-timeout loop should align to ftp's timed_connect, which is a program-internal helper (defined /usr/src/usr.bin/ftp/util.c:1081, declared in ftp's extern.h) — not linkable, so REFERENCE not ADOPT. | ftp/fetch.c:599 calls timed_connect(fd,res->ai_addr,res->ai_addrlen,connect_timeout) while iterating getaddrinfo res — same shape as dial(). ftp uses its own non-blocking-connect helper; mirror its edge-case handling (per-ai fallthrough, SO_ERROR check). fugu adds the addr_blocked guard per-ai which ftp lacks; keep that. getaddrinfo(3) confirmed declared netdb.h:316, implemented lib/libc/asr/getaddrinfo.c. |
+| http_fetch | One HTTPS GET under a wall-clock deadline: dial, build Host/UA/Accept headers + extras, tls_client/tls_configure/tls_connect_socket, deadline-bounded handshake/write/read loops, push into http_resp, cap body. | `/usr/src/usr.bin/ftp/fetch.c:646` tls_connect_socket(3) | **REFERENCE** | M | The libtls calls ARE the base API and are already adopted (drop-in); what to mirror is the TLS_WANT_POLLIN/POLLOUT handshake/read loop idiom from ftp(1)'s program-internal fetch.c. The HTTP request build, deadline plumbing, and body cap are fugu-specific. | ftp/fetch.c:646-666 does the identical tls_client->tls_configure->tls_connect_socket->tls_handshake-loop sequence; fugu correctly extends it with TLS_WANT_POLLIN/OUT + io_wait for non-blocking deadline enforcement (ftp uses funopen/blocking stdio instead). Confirmed tls_connect_socket in TLS_CONNECT(3), tls_config_set_ca_file in TLS_LOAD_FILE(3). No drop-in to swap — already on base libtls; align idioms/error-string handling to ftp's sockerror(). |
+| fetch_body | http_resp body sink: append decoded bytes into struct buf up to a cap, set truncated flag. | — | **KEEP** | S | Callback over fugu's buf module enforcing the download cap; no base analog. | Application size-capping callback. |
+| emit_result | Serialize {id,content,is_error} via fugu json_writer and ip_compose/ip_flush an M_TOOL_RESULT. | — | **KEEP** | S | App-specific result serialization over fugu's json/imsg layers. | Pure glue. |
+| obj_getstr | Helper: fetch a string-typed value for a key from a fugu json object, strdup'd. | — | **KEEP** | S | Thin accessor over fugu json; no base JSON API by charter. | Duplicated logic with kagi.c:field() but local to this file; trivial. |
+| state_clear | Teardown: freezero the Kagi token, free cafile/host/port, tls_config_free, zero the struct. | freezero(3) | **KEEP** | S | App-specific cleanup already built from base freezero + libtls free; nothing to adopt. | Correct secret hygiene: freezero on the token rather than free. |
+| kagi_search_path | Build the Kagi /api/v0/search query path with url_encode'd query and limit=10. | — | **KEEP** | S | Kagi-endpoint-specific URL builder; no base equivalent. | url_encode is a fugu module (url.h), out of scope here. |
+| kagi_format_results | Parse Kagi JSON response: handle error envelope, walk data[] array, extract title/url/snippet of search-type results, field-cap and format up to KAGI_MAX_RESULTS. | — | **KEEP** | M | Kagi-API-specific response formatting; no base analog. | Per-field caps (KAGI_TITLE/URL/SNIPPET_MAX) defend the model against a hostile result flooding context — app policy, must stay. |
+| field | Helper: fetch a string-typed value for a key from a fugu json object, strdup'd. | — | **KEEP** | S | Thin json accessor; identical to fetch_run.c:obj_getstr but file-local static. | Could be de-duplicated with obj_getstr into a shared json helper, but that is a fugu-internal refactor, not a base-adoption finding. |
+
+### `src/ui/ui_curses.c`
+
+_Wide-char curses TUI on a poll(2) loop: the curses/libc primitives (get_wch, mvaddnwstr, wcwidth, mbrtowc, wcrtomb, setlocale, nl_langinfo, resizeterm) are all base-linkable and already used idiomatically — verdict REFERENCE/ADOPT to align with ssh/utf8.c and mandoc/term_ascii.c; the transcript/block model, line editor, imsg pump and slash-command handling are app-specific KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| ui_curses_run | Top-level TUI: locale/UTF-8 setup, unveil/pledge narrowing, initscr+raw/noecho/keypad/nodelay, SIGWINCH sigaction, and the poll(2) loop multiplexing stdin (get_wch) and the imsg fd. | `/usr/src/usr.bin/mandoc/term_ascii.c:93` curs_initscr(3) | **REFERENCE** | M | curses init/raw-mode setup is base-linkable and already idiomatic; the locale forcing mirrors mandoc term_ascii.c:93-94 setlocale(LC_CTYPE,"")->"en_US.UTF-8". The poll(2)+curses+imsg event multiplexing is fugu-specific (no base curses program drives a non-blocking get_wch poll loop), so align idioms only, do not adopt a driver. | setlocale(LC_CTYPE,"") then nl_langinfo(CODESET)!="UTF-8" fallback to en_US.UTF-8/C.UTF-8 is exactly mandoc's UTF8_LOCALE idiom (term_ascii.c:37,93-94) and ssh utf8.c's CODESET test (utf8.c:50-60). nodelay+get_wch drain loop is correct for non-blocking wide input. Consider libevent (base) instead of hand-rolled poll, but charter-neutral; keep poll. |
+| on_winch | SIGWINCH handler: sets a volatile sig_atomic_t flag so the poll loop can resize. | sigaction(2) | **KEEP** | S | trivial async-signal-safe flag setter; no base library equivalent to adopt | Idiomatic: only touches a volatile sig_atomic_t. sa_flags 0 (no SA_RESTART) is deliberate so poll(2) returns EINTR — matches base practice. |
+| block_new / blocks_free / replay_block | Transcript model: grow the block array, free it, and convert a replayed convo part into a typed block. | — | **KEEP** | S | application-specific transcript/block storage; no base equivalent | Uses project xreallocarray/buf helpers (reallocarray(3) is the base primitive underneath, already covered elsewhere). Pure app logic. |
+| prefix_of / attr_of | Map a block kind to its display prefix string and curses attribute (A_BOLD/A_DIM/etc). | curs_attr(3) | **KEEP** | S | app styling table; the A_* constants are base but the mapping policy is fugu-specific | A_BOLD/A_DIM are base curses attrs (curs_attr(3)); only the kind->attr policy is app-specific. Nothing to adopt. |
+| cw | Display width of a wide char, clamping control/unknown (wcwidth==-1) to 1 column. | `/usr/src/usr.bin/ssh/utf8.c:146` wcwidth(3) | **ADOPT** | S | already a thin wrapper over the base wcwidth(3); keep wrapper but align the -1 fallback to base practice | wcwidth(3) is base libc (WCWIDTH(3), declared <wchar.h>). ssh/utf8.c:146 and less/line.c:256 handle wcwidth==-1 explicitly; fugu's clamp-to-1 is a reasonable display choice. Already adopting the base API — this is a correct wrapper, not a reimplementation. |
+| ascii_rep / fold_wcs | Lossy display-only transliteration of common Unicode punctuation to ASCII (smart quotes, dashes, ellipsis, arrows, symbols), with worst-case 4x expansion. | `/usr/src/usr.bin/mandoc/term_ascii.c` | **REFERENCE** | M | mandoc solves the same Unicode->ASCII fold problem but inside term_ascii.c (not a linkable API); mirror its table-driven approach, cannot link it | mandoc -Tascii does exactly this lossy fold but as program-internal code in usr.bin/mandoc/term_ascii.c — REFERENCE, not ADOPT. fugu's hand-maintained switch is fine; consider cross-checking its substitution table against mandoc's for coverage. No base library exports a transliteration function. |
+| utf8_to_wcs | Decode a UTF-8 byte run into a freshly allocated wchar_t array using mbrtowc, substituting U+FFFD on invalid/incomplete sequences and resetting mbstate. | `/usr/src/usr.bin/sort/bwstring.c` mbrtowc(3) | **ADOPT** | S | already built on the base mbrtowc(3); error recovery should mirror base (reset state, advance 1 byte) which it does | **[verified correction: real mbrtowc caller (ssh utf8.c uses mbtowc, not mbrtowc)]** mbrtowc(3) is base libc (MBRTOWC(3), <wchar.h>). ssh/utf8.c:130-131 uses mbtowc + (void)mbtowc(NULL,...) to reset state on error; less/line.c:686 uses mbrtowc with mbstate. fugu's (size_t)-1/-2 handling + memset(&st) reset matches the idiom. Correct use of the base API. |
+| render | Wrap every block to t->cols display columns (word-wrap on spaces, break too-wide glyphs) and, when drawing, emit visual lines with mvaddnwstr + attron/attroff; returns total visual line count. | `man curs_addwstr(3)` | **REFERENCE** | M | mvaddnwstr/attron are base-linkable and used correctly; the column-accurate word-wrap (lastsp tracking, too-wide-glyph break) is fugu-specific — align wrap edge cases to less/mandoc | **[verified correction: no base program demos mvaddnwstr; cite the man page]** mvaddnwstr is base wide curses (curs_addwstr(3), ncurses.h). less/line.c does the analogous wcwidth-driven column accounting for wrap (line.c:256,388,394). The wrapping algorithm itself is app layout (KEEP-ish) but the curses calls are base — net REFERENCE: keep the algorithm, ensure wcwidth==0 combining chars are handled as less does. |
+| draw | Full-screen repaint: erase, render scrollback window, draw reverse-video status bar, draw the horizontally-scrolled input line tracking the cursor by display columns, position cursor, refresh. | curs_addstr(3) | **KEEP** | M | uses base curses primitives but the status-bar/input-line layout and horizontal-scroll-to-cursor are entirely app UI | All curses calls (erase/mvaddch/mvaddnstr/mvaddnwstr/move/clrtoeol/refresh/attron A_REVERSE) are base and used idiomatically, but the composed screen layout is fugu-specific rendering — nothing to adopt beyond the primitives already linked. |
+| do_resize | On SIGWINCH: TIOCGWINSZ ioctl, resizeterm to the new size, re-read getmaxyx, force a full clear. | resizeterm(3) | **ADOPT** | S | resizeterm(3) and getmaxyx are base-linkable and already used; TIOCGWINSZ ioctl is the standard base resize idiom | resizeterm(3) is a base libcurses call (confirmed man resizeterm(3), <curses.h>). The TIOCGWINSZ->resizeterm->getmaxyx->clearok sequence is the canonical curses resize handler. Already adopting the base API correctly. |
+| drain_imsg | Read available imsg bytes then dispatch every decoded fugu_msg via handle_msg until the buffer drains. | imsg_init(3) | **KEEP** | S | thin pump over the project's ip_* imsg wrapper; the imsg layer is audited in imsgproto, not here | Underlying transport is base imsg (rewritten to imsgbuf_* in -current) but accessed through fugu's ip_read/ip_get abstraction — the adopt decision belongs to the imsgproto unit, not this UI file. Here it is pure dispatch glue. |
+| handle_msg | Dispatch streamed protocol messages (M_DELTA/M_MESSAGE/M_TOOL_CALL/M_USAGE/M_TURN_DONE/M_SUMMARY/M_ERROR) into transcript blocks, convo state, journal appends, and status updates; parses tool-call/usage JSON with jsmn. | — | **KEEP** | M | application protocol state machine; JSON via vendored jsmn (allowed); no base equivalent | Pure fugu protocol semantics over convo/journal/json helpers. JSON parsing is the vendored jsmn permitted by charter. Nothing in base to align to. |
+| handle_input | Translate a wint_t key (KEY_* function keys vs control/plain chars) into scrolling, cursor motion, line edits (backspace/delete/home/end/kill), quit, and submit. | curs_get_wch(3); libedit/editline(3) | **KEEP** | M | consumes base curses KEY_* codes but implements a bespoke single-line emacs-ish editor | **[verified correction: libedit IS in base (/usr/lib/libedit.so, man editline(3)) — the earlier "no editline in base" claim was wrong.]** Still KEEP, but for the right reason: libedit's `el_wgets` runs its own blocking read loop and cannot interleave with the curses `nodelay` get_wch + imsg `poll(2)` loop, which must edit the input line *while* streaming response deltas render. KEY_* come from get_wch with KEY_CODE_YES; the editor is correctly hand-rolled. |
+| in_insert | Insert one wchar_t at the cursor, growing the wide input buffer (doubling) via xreallocarray and memmove-ing the tail. | reallocarray(3) | **KEEP** | S | app gap-free insert into a wchar_t buffer; only the allocator primitive is base and already wrapped | reallocarray(3) is base and already used via xreallocarray; the editor buffer management is app-specific. Nothing further to adopt. |
+| submit | Encode the wide input line back to UTF-8 with wcrtomb, intercept slash-commands via ui_classify, else append a user block, update convo+journal, build the request and ip_compose/ip_flush an M_SUBMIT, set busy. | `/usr/src/lib/libc/citrus/citrus_utf8.c` wcrtomb(3) | **ADOPT** | S | the wcrtomb wide->multibyte encode is base libc and used correctly; the surrounding submit/convo/imsg flow is app logic (KEEP) but the encode primitive is a clean ADOPT | **[verified correction: wcrtomb impl (ssh utf8.c never calls wcrtomb)]** wcrtomb(3) is base libc (<wchar.h>); fugu's MB_LEN_MAX buffer + mbstate_t + skip-on-(size_t)-1 loop is the standard inverse of the mbrtowc decode. No base program calls wcrtomb (only gnu/ subtrees), so cite the symmetric mbrtowc reference; the API itself is the win. Rest of submit (convo_build/ip_compose) is app KEEP. |
+| do_command | Execute intercepted slash commands: /quit, /clear (journal marker + convo_clear), /compact (build convo, ip_compose M_SUMMARIZE, set compacting/busy). | — | **KEEP** | S | application command dispatch; no base equivalent | Pure fugu UX/protocol logic (clear/compact context management). Nothing in base to adopt or mirror. |
+
+### `src/ui/ui.c, src/ui/journal.c, src/ui/convo.c`
+
+_Mostly KEEP app-specific glue; two genuine base alignments: getline(3) is already correctly adopted for line input, and the hand-rolled write_all partial-write loop should align to base's atomicio (program-internal, REFERENCE) — plus open_memstream(3) is the base string-builder idiom behind fugu's local buf/json_writer._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| line_replay | Format one replayed transcript part (user/tool/assistant/info) to stdout when resuming a session in line mode. | — | **KEEP** | S | app-specific transcript-formatting callback; no base equivalent | Plain printf with a fugu convo_part switch. Nothing to adopt. |
+| ui_classify | Map a typed line to a slash-command enum (/clear, /compact, /quit, /exit). | — | **KEEP** | S | trivial strcmp command dispatch; no base option | A handful of strcmp(3) comparisons. Already idiomatic; nothing linkable to replace it. |
+| line_collect | Pump the imsg channel to stdout for one reply turn (deltas, message persistence, tool calls, done/error). | — | **KEEP** | S | drives fugu imsgproto/convo/journal; no base option | Loop over ip_get/ip_read is fugu's own imsgproto wrapper (over base imsgbuf_*). The protocol pump itself is app logic. |
+| line_compact | Wait for an M_SUMMARY reply to a /compact request, copying the summary into a buf. | — | **KEEP** | S | fugu imsgproto summary handshake; no base option | Same imsg pump pattern as line_collect, specialized for the summarize round-trip. |
+| ui_line_run | The readline-free line-mode REPL: pledge/unveil narrowing, getline(3) read+chomp loop, command dispatch, submit/compact round-trips, journal append, failed-turn rollback. | `/usr/src/usr.bin/ftp/fetch.c:796` getdelim(3)/getline(3) | **KEEP** | S | orchestration glue that already uses the base getline(3) idiom correctly | This is the file's central line-input seam and it ALREADY adopts the base idiom: getline(&line,&lcap,stdin) with a manual \n/\r chomp and free(line) at exit — identical to base callers like ftp/fetch.c:796 and grep/file.c. Confirmed: declared /usr/include/stdio.h:159, man getdelim(3). No change needed; do NOT regress to fgets+fixed buffer. The surrounding submit/compact/journal flow is app logic (KEEP). |
+| main | fugu-ui entry: log_init, ignore SIGPIPE, getopt(-a/-R), verify imsg fd 3 is a socket via fstat, isatty TTY detection, choose curses vs line front-end, teardown. | getopt(3) | **KEEP** | S | setup/dispatch glue already using base getopt/isatty/fstat idiomatically | Already uses the canonical base APIs (getopt while-switch, isatty+TERM probe, fstat+S_ISSOCK guard, signal(SIGPIPE,SIG_IGN)). Nothing to adopt or realign. |
+| sessions_dir | Compose "$HOME/.fugu/sessions" into a caller buffer; fail if HOME unset or it overflows. | snprintf(3) | **KEEP** | S | getenv+snprintf path build; truncation-checked, already idiomatic | Correct snprintf >= bufsz truncation check. The base alternative for HOME expansion would be nothing more linkable; this is the right pattern. KEEP. |
+| valid_id | Reject session ids that aren't a safe basename (no '/', not '.'/'..') before interpolating into a path. | — | **KEEP** | S | no base linkable path-component validator (base does this ad hoc per program) | Searched ssh/sftp-server.c and friends; base performs the same ../slash rejection inline per program (no shared API). fugu's strchr('/')+strcmp('.'/'..') is the standard hand-rolled check. KEEP. |
+| write_all | Loop write(2) until all n bytes are written, retrying on EINTR; -1 on real error. | `/usr/src/usr.sbin/vmd/atomicio.c:56` | **REFERENCE** | S | base's atomicio() is the canonical full-write loop but is a per-program vendored file, not a linkable base symbol | Confirmed atomicio is NOT in /usr/include and NOT in libutil (util.h) — it is copied into ssh, nc, cvs, vmd, vmd, ldattach, sendbug independently. So we cannot link it (charter: base+jsmn only). fugu's write_all is correct but should mirror atomicio's edge cases at /usr/src/usr.sbin/vmd/atomicio.c:56-71: it handles EAGAIN (poll) and treats write returning 0 as EPIPE; write_all currently only handles EINTR. For a blocking journal fd that's fine, but align the 0-return/EAGAIN handling if the fd ever becomes non-blocking. REFERENCE: copy the idiom, don't link. |
+| journal_unveil | unveil(2) the sessions dir rwc before the unveil lock so the journal fd can be created. | unveil(2) | **KEEP** | S | directly uses base unveil(2); nothing to adopt | Already a base syscall used idiomatically (best-effort: missing dir simply disables journaling). KEEP. |
+| journal_open | Open/create the append-only jsonl session file: derive a sortable epoch-pid id (or validate a resume id), build the path, open O_WRONLY\|O_APPEND\|O_CREAT 0600. | `/usr/src/usr.bin/ftp/fetch.c` open(2) | **KEEP** | S | already uses the base O_APPEND\|O_CREAT open(2) idiom; id scheme is app-specific | O_APPEND open with 0600 perms matches base append-log callers (ftp/fetch.c, cvs/history.c use O_APPEND). Time-based id via time(NULL)+getpid avoids any timezone files (good for pledge). No linkable base helper for the id scheme. KEEP. |
+| journal_foreach | Open a session jsonl by id and invoke a callback per non-empty, chomped line. | `/usr/src/usr.bin/ftp/fetch.c:874` getdelim(3)/getline(3) | **KEEP** | S | already uses the base fopen+getline(3) read-loop idiom correctly | This is the read-side line seam and it ALREADY adopts base: fopen("r") + while(getline(&line,&cap,fp)!=-1) with \n/\r chomp and free(line)+fclose — the exact pattern in ftp/fetch.c:874 and grep/file.c. Confirmed in /usr/include/stdio.h:159. No change; the surrounding callback dispatch is app logic. |
+| journal_append | Append one json record plus a newline to the journal fd; disable journaling on first write error. | — | **KEEP** | S | thin wrapper over write_all (covered separately); no base option | Calls write_all twice (record + '\n'). The full-write idiom is the REFERENCE flagged on write_all; this wrapper itself is app policy (drop-on-error). KEEP. |
+| journal_close | Close the journal fd if open and mark it -1. | close(2) | **KEEP** | S | trivial close(2) wrapper; no base option | Nothing to adopt. |
+| convo_add_role | Append a {"role":..,"content":..} message built from plain text via fugu's json_writer into a buf, then xstrdup it into the message array. | `/usr/src/sbin/iked/ikev2.c:6508` open_memstream(3) | **KEEP** | M | replacing the project-wide buf/json_writer abstraction with open_memstream would not be a net win and crosses out of this file | FYI seam: open_memstream(3) IS base (declared /usr/include/stdio.h:256; real users iked/ikev2.c:6508, radiusd/radiusd_standard.c:345) and is the canonical base way to build a string by fprintf-ing into a FILE*. But fugu deliberately uses a vendored buf + json_writer shared project-wide; swapping in open_memstream here only (not the buf abstraction in buf.c) would be inconsistent. KEEP this function; consider open_memstream only if/when the buf abstraction itself is reviewed. |
+| convo_add_user | Append a user-role message (thin wrapper over convo_add_role). | — | **KEEP** | S | one-line app wrapper; no base option | |
+| convo_set_summary | Replace the active context with a /compact summary stored as a user/assistant pair (so role alternation the Messages API requires stays valid). | — | **KEEP** | S | app-specific Messages-API context model; no base option | Pure conversation-model policy. |
+| convo_marker_clear | Build the journal marker {"fugu":"clear"} recording a context reset. | — | **KEEP** | S | app journal-marker schema via json_writer; no base option | Same json_writer/buf seam as convo_add_role; open_memstream note applies project-wide, not here. |
+| convo_marker_compact | Build the journal marker {"fugu":"compact","summary":..} carrying the summary. | — | **KEEP** | S | app journal-marker schema; no base option | |
+| convo_add_raw | Append a raw JSON message (copied, NUL-terminated) to the message array. | — | **KEEP** | S | xmalloc+memcpy+reallocarray growth of an app array; no base option | Uses fugu's x-allocators (over base reallocarray(3)); the array model is app logic. KEEP. |
+| convo_truncate | Free messages from index n upward, shrinking the active context. | — | **KEEP** | S | app array teardown; no base option | |
+| convo_free | Free all messages and the message array. | — | **KEEP** | S | app array teardown; no base option | |
+| convo_build | Serialize the message array into a JSON array projection for each M_SUBMIT. | — | **KEEP** | S | concatenation into fugu's buf; no base option | Joins pre-built JSON message strings with commas inside [ ]. Pure app projection over buf. |
+| replay_tool_use | Format one tool_use block (name + input) and hand it to the front-end like a live M_TOOL_CALL during replay. | — | **KEEP** | S | app replay formatting over jsmn json helpers; no base option | Uses fugu json_obj_get/json_tok_strdup (jsmn-backed). App logic. |
+| convo_replay | Parse one stored message and emit display parts (string content, or array blocks: assistant text + tool_use; skip tool_result). | — | **KEEP** | M | app-specific Messages-API content-block walk over vendored jsmn; no base option | Charter-allowed jsmn is the JSON parser; the block-shape interpretation is fugu domain logic. KEEP. |
+| record_kind | Classify a journal line as message / clear-marker / compact-marker (extracting summary) / skip. | — | **KEEP** | S | app journal record discriminator over jsmn; no base option | |
+| resume_line | Apply one journal line during resume: keep+draw a message, reset context on /clear, or restore the summary on /compact — reconstructing the active context. | — | **KEEP** | M | app resume/replay state machine; no base option | Core of session resume semantics. App logic. |
+| convo_resume | Replay a session journal by id back into a fresh convo (and via callback into the front-end transcript). | — | **KEEP** | S | thin driver over journal_foreach; no base option | Wires resume_ctx into journal_foreach (whose getline read-loop is the base idiom already in use). KEEP. |
blob - /dev/null
blob + 42bfacdc31a806121abd926fd0ee48884dc6edfc (mode 644)
--- /dev/null
+++ docs/base-idiom-audit.md
+# fugu ↔ OpenBSD base: idiom audit brief
+
+**For:** a fresh agent/session with no prior context.
+**Type:** read-only audit producing a written report. **Do not refactor in this pass.**
+
+## Goal
+
+fugu is an OpenBSD-native, privilege-separated AI coding agent in C (5 separate
+binaries: `fugu` + `fugu-{ui,net,exec,fetch}`), sandboxed per-process with
+`pledge(2)`/`unveil(2)`, base + a vendored `jsmn.h` only. While building it we
+hand-rolled a number of utilities (a growable buffer, a JSON emitter, an
+HTTP/1.1 client, an SSE parser, config-file secrecy checks, `poll(2)` event
+loops, directory listing/sorting, etc.).
+
+**The task:** go function by function through fugu's source and decide, for each,
+whether OpenBSD **base** already offers an idiomatic equivalent we should be
+using or aligning to — a "seam" where our custom code overlaps something base
+already does well. Produce a prioritized report. Implementation comes later, as a
+separate pass, after the user reviews the findings.
+
+## Environment & access
+
+- **Host:** `ssh server` → OpenBSD 7.9-current, amd64. Non-interactive `ssh
+ server '<cmd>'` works. **Never** `pkill -f fugu` on the host (it shares the ssh
+ session — use `pkill -x fugu-net` etc. if ever needed).
+- **Base source:** `/usr/src` on the host (NOT `/var/src`). Ports: `/usr/ports`.
+ Headers also in `/usr/include`; manuals via `man`/`mandoc` on the host.
+- **fugu source:** edit locally at `/home/isaac/org/tech/coding/fugu`, then
+ `rsync -a --exclude='*.o' <local>/ server:fugu/` and build on the host
+ (`ssh server 'cd fugu && make'`). The host copy is `~/fugu` (`/home/isaac/fugu`).
+ This audit is read-only, so you mostly just read the local tree and grep
+ `/usr/src` on the host.
+- **Already validated:** the whole thing builds clean and passes 20 regress
+ suites; an OpenBSD port exists under `ports/fugu/`. None of that should change.
+
+## What counts as a "seam" — classify every function
+
+For each fugu function (or cohesive group), assign exactly one verdict:
+
+- **ADOPT** — base exposes a *linkable* idiomatic equivalent (a `libc`,
+ `libutil`, `libevent`, or `libtls` API, or a base header) that is a drop-in or
+ near-drop-in. These are the wins. Examples to confirm: `scandir(3)`,
+ `check_file_secrecy()`, `open_memstream(3)`, `recallocarray(3)`.
+- **REFERENCE** — base *solves the same problem* but only inside a base program
+ (not a library we can link, e.g. `ftp(1)`'s HTTP client). We can't reuse the
+ code, but we should align our approach/idioms/edge-case handling to it. Cite
+ the base file and what to mirror.
+- **KEEP** — no base equivalent exists; the custom code is justified. Record the
+ one-line reason (so the report is a complete accounting, not just a TODO list).
+
+A seam that would require a **non-base dependency** (a ports library) is out of
+scope by fugu's charter — note it as KEEP with the reason "no base option."
+`libevent` **is** base and therefore allowed, but see the tradeoff note below.
+
+## Method (how to check base — verify, don't assert)
+
+For every claim "base has X," prove it on the host:
+1. **Man page:** `ssh server 'man 3 scandir'` (or section 1/2/5) — confirms it's a
+ supported public API, shows the signature.
+2. **Header:** `grep -rn 'scandir' /usr/include` — confirms it's declared in base.
+3. **A real user in base:** `grep -rln 'scandir(' /usr/src` — shows the idiomatic
+ call site to copy. Cite `path:line`.
+Never claim an API from memory; OpenBSD prunes/renames (e.g. the imsg API was
+rewritten to `imsgbuf_*`). If unsure whether something is a true library symbol
+vs. a per-program static, check whether it's in a `lib/` dir or a program dir.
+
+## Source inventory (the work-list)
+
+29 source files under `fugu/src/` (ignore the generated `parent/parse.c`; audit
+`common/parse.y`). Per-file purpose and the **lead** to investigate first:
+
+| fugu file | purpose | first lead in /usr/src | prior verdict |
+|---|---|---|---|
+| `common/log.c` | warn/warnx/fatal/log_* | `usr.sbin/bgpd/log.c`, `ntpd/log.c` (fugu's is derived from this; check for drift) | KEEP/SYNC |
+| `common/util.c` | `xmalloc`/`xcalloc`/`xreallocarray`/`xstrdup`/`xasprintf` | `usr.bin/ssh/xmalloc.c` (canonical); libc `reallocarray`/`recallocarray`/`freezero` | REFERENCE |
+| `common/buf.c` | growable byte buffer (`buf_init/append/printf/puts/putc/reserve/free`) | `lib/libutil/imsg-buffer.c` (`ibuf_*`); `open_memstream(3)` (user: `usr.sbin/radiusd/radiusd_standard.c`) | EVALUATE |
+| `common/json.c` | JSON emitter + jsmn wrapper | none (no base JSON) | KEEP |
+| `common/imsgproto.c` | chunked framing over libutil imsg | `lib/libutil/imsg.c` + `imsg-buffer.c` (`imsgbuf_*`, `imsg_compose`, `imsg_get_ibuf`); base has no chunking | REFERENCE/EVALUATE |
+| `common/http.c` | HTTP/1.1 request build + chunked decode | `usr.bin/ftp/fetch.c` (base HTTP/HTTPS client) | REFERENCE |
+| `common/sse.c` | server-sent-events parser | none | KEEP |
+| `common/conf.c` | config model + `conf_check_secrecy` + merge/print | `check_file_secrecy()` in `usr.sbin/bgpd/parse.y`, `dvmrpd/parse.y`, `eigrpd/parse.y` | ADOPT |
+| `common/confload.c` | `conf_load` (getpwuid/getgrouplist + StrictModes + overlay) | base daemons' config load; `getgrouplist(3)` | REFERENCE |
+| `common/parse.y` | yacc grammar + lexer (`lgetc/lungetc/findeol/pushfile/popfile/symset/symget`) | `usr.sbin/httpd/parse.y` (the canonical template — diff against it) | SYNC (high value) |
+| `common/url.c` | URL parse + RFC3986 encode + anti-injection | `usr.bin/ftp/fetch.c` (`url_get`/parsing) | REFERENCE |
+| `common/html2text.c` | HTML→text | none | KEEP |
+| `common/tlsconn.c` | libtls client config/connect/read/write + poll deadline | `usr.bin/ftp/fetch.c` libtls usage; `man tls_connect_socket`/`tls_config_set_protocols` | REFERENCE |
+| `parent/parent.c` | privsep coordinator: spawn (fork+exec+socketpair), imsg relay (poll), secret delegation, session dir list/resolve (opendir+qsort), conf load | (a) `usr.sbin/httpd/proc.c` + `relayd/proc.c` (privsep process helper); (b) `sbin/dhcpleased/*.c` (libevent + imsg event loop); (c) `scandir(3)` (user: `usr.bin/diff/diffdir.c`) | EVALUATE (high value) |
+| `net/net.c` | net worker main (tiny) | — | KEEP |
+| `net/net_run.c` | Anthropic agent loop + imsg + http calls | request/response is synchronous — compare to base's blocking-client style | REFERENCE |
+| `ui/ui.c` | line-mode front end + command dispatch | — | KEEP |
+| `ui/ui_curses.c` | wide-char curses TUI | `lib/libcurses`; base curses users (`usr.bin/top`, `systat`) for wide-char/`get_wch` idioms | REFERENCE |
+| `ui/journal.c` | append-only jsonl session log | none | KEEP |
+| `ui/convo.c` | conversation model | none (app-specific) | KEEP |
+| `exec/exec.c` | exec worker main + getopt + pledge | pledge/unveil idioms (already idiomatic) | KEEP |
+| `exec/exec_run.c` | sandbox (unveil+execpromises) + tool dispatch | base privsep `unveil`/`pledge` + execpromises usage | KEEP/REFERENCE |
+| `exec/tools_file.c` | read/write/edit (atomic temp+rename) | base atomic-write idiom (`mkstemp`+`rename`); `freezero` | REFERENCE |
+| `exec/tools_shell.c` | `run_capture` (pipe+fork+exec+poll timeout) | base fork+exec+pipe capture (no `popen` — needs stderr+timeout) | KEEP/REFERENCE |
+| `fetch/fetch.c` | fetch worker main | — | KEEP |
+| `fetch/fetch_run.c` | SSRF guard (getaddrinfo + addr_blocked) + libtls fetch | `getaddrinfo(3)` idioms; no base SSRF helper | KEEP/REFERENCE |
+| `fetch/kagi.c` | Kagi API request/format | none | KEEP |
+
+## High-value seams (confirmed; investigate these first)
+
+These four are the most likely real wins — all confirmed present in base:
+
+1. **`conf_check_secrecy` → base `check_file_secrecy()`** (`bgpd/parse.y` et al.).
+ The base function (StrictModes-style 0600/owner check) is copied verbatim across
+ daemons. fugu should match it byte-for-byte so reviewers recognize it.
+2. **`parent` directory listing → `scandir(3)` + comparator** (`diff/diffdir.c`).
+ `resolve_last()`/`list_sessions()` hand-roll opendir+readdir+`qsort`; `scandir`
+ is the one-call idiom with a sort comparator.
+3. **`common/parse.y` lexer → diff against `usr.sbin/httpd/parse.y`.** Our lexer is
+ the classic pf/httpd template; base has since evolved it (e.g. `igetc`,
+ `expand_macros`, growable token buffer, `check_file_secrecy`). Sync to current.
+4. **`buf.c` → `ibuf`/`open_memstream`.** Decide deliberately: `ibuf` (libutil) for
+ wire/byte buffers, `open_memstream(3)` for string building. Tradeoff: `buf` is
+ tiny and dependency-free; quantify what adopting either actually buys.
+
+## The big design question (flag, don't pre-decide)
+
+**`poll(2)` loops → `libevent` + the base imsg-privsep pattern.** Every base
+privsep daemon (`dhcpleased`, `slaacd`, `unwind`, `httpd`, `relayd`) is built on
+`libevent` (`event_init`/`event_add`/`event_dispatch`) plus a shared
+`imsg_event_add`/`dispatch_imsg`/`proc.c` idiom. fugu hand-rolls `poll()` in the
+parent relay and each worker. This is the single largest stylistic divergence
+from base — **but** fugu is deliberately foreground, per-invocation, and *not* a
+daemon, and its flows are largely synchronous request/response. Treat this as an
+architectural recommendation with an honest tradeoff analysis (idiomatic-ness and
+`proc.c` reuse vs. added machinery for a non-daemon), **not** an assumed adoption.
+Likewise evaluate whether `parent.c`'s spawn/relay should be rebuilt on
+`httpd/proc.c`'s privsep helper.
+
+## Output: the deliverable
+
+Write findings to `fugu/docs/base-idiom-audit-findings.md`:
+
+- **Top of file:** a prioritized "Adopt these" list (the clear ADOPT wins), then
+ "Evaluate" (tradeoff calls), then "Keep (justified)" — each one line.
+- **Body:** one row per fugu function (or tight group):
+ `File:function | Purpose | Base equivalent (path:line + man page) | Verdict | Effort (S/M/L) | Risk/Tradeoff | Notes`.
+- Every ADOPT/REFERENCE row must cite a real `path:line` in `/usr/src` (or a man
+ page) you actually looked at. KEEP rows state the reason in one phrase.
+- It must be a *complete* accounting: every function appears exactly once.
+
+## Suggested execution (this is a parallelizable, read-heavy audit)
+
+Run it as a multi-agent **Workflow** (the user has opted into orchestration for
+this; confirm "ultracode"/explicit opt-in is still in effect before launching):
+
+- **Phase 1 — audit (fan out):** one agent per source file or tight group (~14
+ units; e.g. group `net/*`, `exec/*`, `fetch/*`, `ui/*`, and each `common/*`
+ individually since common holds the densest seams). Each agent: reads the fugu
+ file locally, enumerates its functions, then for each greps `/usr/src` +
+ checks the man page/header on `server`, and returns **structured** findings
+ (use a JSON schema: array of `{func, purpose, base_equiv, path_line, verdict,
+ effort, tradeoff}`). Give every agent this brief's "Method" and "classify"
+ rules and the relevant row(s) from the inventory as its starting lead.
+- **Phase 2 — synthesize (single agent):** merge all findings, de-dup
+ cross-cutting ones (e.g. `check_file_secrecy` touches conf.c + parse.y), sort
+ into the Adopt/Evaluate/Keep buckets, and write the findings file. Then a
+ **completeness check**: assert every inventory file is covered and every
+ ADOPT/REFERENCE claim carries a real citation; re-audit anything missing.
+
+## Guardrails / non-goals
+
+- **Read-only.** Produce the report; change no code, Makefiles, or the port.
+- **Honor fugu's charter:** OpenBSD-only, base + vendored `jsmn` only, per-process
+ `pledge`/`unveil`, secrets never in argv/env/journal. Reject any seam needing a
+ ports dependency.
+- **Verify every base claim** against the actual tree/man page with a citation;
+ the imsg API in particular was rewritten — confirm current symbols.
+- **Separate library reuse (ADOPT) from style alignment (REFERENCE).** Don't
+ recommend linking a base *program's* internal code.
+- For design-level seams (libevent, `proc.c`), present the tradeoff and a
+ recommendation; the user decides.
blob - /dev/null
blob + 93124eb9cb6a5e0b43ff1618811cc1b0577b980a (mode 644)
--- /dev/null
+++ etc/fugu.conf.sample
+# fugu.conf.sample - configuration for fugu(1).
+#
+# Installed as /etc/fugu.conf.sample. Copy to /etc/fugu.conf to use. fugu reads
+# ALL of its configuration -- including the secrets (api_key, anthropic_key,
+# kagi_token) -- from this single file. It is installed set-group-id _fugu, so
+# the parent can read a root-owned /etc/fugu.conf that the model's tools cannot:
+#
+# install -o root -g _fugu -m 0640 /etc/fugu.conf.sample /etc/fugu.conf
+#
+# It MUST be mode 0640 (or stricter), owned by root, group _fugu; fugu refuses to
+# start if it is world-accessible or group-writable. See fugu.conf(5).
+
+# Default model and per-turn generation budget.
+#model "claude-sonnet-4-6"
+#max_tokens 4096
+
+# API wire protocol: "anthropic" (default) or "openai". The "openai" provider
+# speaks chat/completions, which most other vendors emulate; point api_host at
+# the vendor, override api_path if it does not serve at /v1, and set a matching
+# model and api_key.
+#provider "openai"
+#api_host "openrouter.ai"
+#api_path "/api/v1/chat/completions" # OpenRouter; default is /v1/chat/completions
+#model "anthropic/claude-sonnet-4.6"
+
+# Several providers at once. The flat keys above define the default provider;
+# each "provider" block adds another, switchable at runtime with /model (which
+# lists every provider's models, tagged by name). See fugu.conf(5).
+#provider "gpt" {
+# type "openai"
+# api_key_file "/home/me/.fugu/openai.key"
+# model "gpt-5"
+#}
+
+# System prompt sent on every turn. Unset = fugu's concise built-in default
+# (identity, terse-tone rules, scope/style defaults, tool guidance); set this
+# to replace it. A string setting, so keep it on one logical line; end a line
+# with \ to continue.
+#system "You are a terse assistant for an OpenBSD C codebase."
+
+# Advisory token budget for the conversation sent to the model.
+#context_limit 200000
+
+# Let the file tools modify the working tree (default yes).
+#allow_write yes
+
+# Let commands run by the shell tool use the network (default no). When no,
+# the exec process and its children are pledged without "inet", so commands
+# such as ftp(1), nc(1) and "git fetch" fail by design.
+#allow_subprocess_net no
+
+# Offer the brokered web search/fetch tools to the model.
+#web_search yes
+
+# Let the model make arbitrary HTTPS requests (the http_request tool) to a
+# restricted set of hosts. Only offered when http_allow is set; a request must
+# match an http_allow pattern and no http_block pattern. Patterns are
+# host[/pathprefix]; a leading dot matches subdomains. fugu adds no credentials
+# of its own -- the model supplies its own headers. See fugu.conf(5).
+#http_allow "api.github.com .githubusercontent.com"
+#http_block "api.github.com/admin"
+
+# Render the interface as ASCII instead of UTF-8 (default no).
+#ascii no
+
+# Per-user / per-group scoping, first match wins (see fugu.conf(5)).
+#match group "wheel" {
+# allow_subprocess_net yes
+#}
blob - /dev/null
blob + b4ad8ceb3a3eb594b00d0ab490ecc51d3b4cfc82 (mode 644)
--- /dev/null
+++ man/fugu.1
+.\" Copyright (c) 2026 Isaac <isaac@itm.works>
+.\"
+.\" Permission to use, copy, modify, and distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd June 21, 2026
+.Dt FUGU 1
+.Os
+.Sh NAME
+.Nm fugu
+.Nd OpenBSD-native AI coding agent
+.Sh SYNOPSIS
+.Nm
+.Op Fl cdlnv
+.Op Fl r Ar id
+.Sh DESCRIPTION
+.Nm
+is an interactive AI coding agent that pairs the Anthropic Messages API with a
+set of local tools \(en reading, writing and editing files, running
+.Xr ksh 1
+commands, searching the tree, and brokered web search and fetch \(en so that a
+language model can work inside a project under the user's direction.
+.Pp
+.Nm
+is built as five privilege-separated programs that the parent
+.Xr fork 2 Ns / Ns Xr execve 2 Ns s
+at startup and that speak to one another only through the parent over
+.Xr imsg 3 :
+.Pp
+.Bl -tag -width fugu-fetchXX -compact
+.It Nm
+parent: reads configuration, delegates secrets, and relays every
+worker-to-worker message.
+Pledge
+.Qq stdio proc .
+.It Nm fugu-ui
+the terminal front end (a
+.Xr curses 3
+transcript on a tty, or a line reader otherwise).
+.It Nm fugu-net
+the only process holding the model API key (or keys, when several providers are
+configured); it speaks TLS to the configured model endpoints and nothing else.
+.It Nm fugu-exec
+runs the file and shell tools with
+.Em no
+network access; subprocesses are confined to the project directory.
+.It Nm fugu-fetch
+performs web search and fetch and parses untrusted HTML in an address space
+that holds neither the API key nor the ability to
+.Xr execve 2 .
+.El
+.Pp
+Each process restricts itself with
+.Xr pledge 2
+and
+.Xr unveil 2
+to the least authority it needs, so a flaw in one (a prompt injection that
+subverts tool execution, a memory bug in HTML parsing) cannot reach the
+network, the credential, or files outside the working tree.
+.Nm
+holds no
+.Xr setuid 2
+privilege and runs no daemon, one invocation per session.
+Its only privilege is set-group-id
+.Sy _fugu ,
+used by the parent solely to read the root-owned
+.Pa /etc/fugu.conf ;
+the parent sheds that group as soon as the secrets are delegated, and every
+worker sheds it at startup, so neither the workers nor the shell tools they run
+can reach
+.Pa /etc/fugu.conf
+or any other
+.Sy _fugu Ns -readable
+file.
+.Pp
+On startup
+.Nm
+reads its configuration (see
+.Xr fugu.conf 5 ) ,
+opens a session journal under
+.Pa ~/.fugu/sessions ,
+and presents a prompt.
+Type a message and press
+.Ic Enter
+to send it; the assistant's reply streams back, and any tool calls it makes are
+shown as they run.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl c
+Resume the most recently modified session, continuing its journal.
+.It Fl d
+Keep logging to standard error for the whole run, including while the session is
+active.
+Startup errors \(en such as a configuration syntax error \(en always go to
+standard error; once the interface takes over the terminal, logging normally
+switches to
+.Xr syslog 3
+so it does not paint over the display.
+.Fl d
+suppresses that switch, which is useful for debugging at the cost of a garbled
+screen.
+.It Fl l
+List the saved sessions \(en id, last-modified time, message count and a
+preview of the first message \(en newest first, then exit.
+.It Fl n
+Parse the configuration, resolve the effective settings for the invoking user,
+print them with secrets redacted, and exit without starting a session.
+.It Fl r Ar id
+Resume the session with the given
+.Ar id
+(as shown by
+.Fl l ) ,
+continuing its journal.
+.It Fl v
+Be more verbose; repeat for more detail.
+.El
+.Sh COMMANDS
+While a session is running, a line beginning with a slash is interpreted as a
+command rather than sent to the model:
+.Bl -tag -width "/compactXX"
+.It Ic /clear
+Reset the active context to empty so the next message starts a fresh
+conversation.
+The journal is preserved on disk and the prior transcript stays on screen as
+scrollback; a marker records the reset.
+.It Ic /compact
+Replace the active context with a model-written summary of the conversation so
+far (one extra API call), reducing the tokens carried into later turns.
+The full journal is preserved; a marker records the summary.
+.It Ic /model
+Open a fuzzy-searchable menu of the models offered by every configured provider
+and switch to the one chosen for the rest of the session.
+The list is fetched live from each provider's model endpoint; when more than one
+provider is configured each entry is tagged with its provider name, and choosing
+a model also makes its provider the active one
+.Pq see Sx PROVIDER BLOCKS in Xr fugu.conf 5 .
+In the menu, type to filter,
+.Ic Up Ns / Ns Ic Down
+(or
+.Ic ^P Ns / Ns Ic ^N )
+to move the selection,
+.Ic Enter
+to choose, and
+.Ic Esc
+to cancel.
+.It Ic /quit , Ic /exit
+End the session.
+.El
+.Pp
+A slash name that is not one of the built-ins above is looked up in
+.Pa ~/.fugu/skills/
+.Pq see Sx FILES :
+on a hit, the skill's body is submitted as a user message, with any text typed
+after the name appended as a second paragraph.
+A marker records the invocation in the journal so a resumed session reproduces
+it.
+.Pp
+.Ic Tab
+.Pq or Ic Shift-Tab No to go backward
+on a partial slash command cycles through the commands that match what has been
+typed, built-ins first and then loaded skills alphabetically.
+.Sh KEYS
+The
+.Xr curses 3
+front end edits the input line with modal,
+.Xr vi 1 Ns -like
+keys.
+It starts in insert mode, where typed characters are inserted at the cursor;
+.Ic Esc
+switches to normal mode for navigation and editing commands.
+.Pp
+These keys work in either mode:
+.Bl -tag -width "PgUp / PgDnXX"
+.It Ic Enter
+Send the current line.
+.It Ic Tab , Ic Shift-Tab
+On a line beginning with a slash, cycle forward
+.Pq Ic Tab
+or backward
+.Pq Ic Shift-Tab
+through the slash commands that match what has been typed.
+.It Ic PgUp / PgDn
+Scroll the transcript a page at a time; the newest output is shown again after
+.Ic PgDn .
+.It Ic Up / Down
+Recall the previous or next prompt sent this session.
+.It Ic Ctrl-L
+Repaint the screen.
+.It Ic Ctrl-C
+Clear the input line, or quit when it is already empty.
+.It Ic Ctrl-D
+Quit when the input line is empty.
+.El
+.Pp
+In normal mode:
+.Bl -tag -width "i a A I o OXX"
+.It Ic h l 0 $ ^ w b e
+Move by character, to the start or end of the line, to the first non-blank, or
+by word.
+.It Ic i a A I o O
+Enter insert mode \(en before or after the cursor, or at the start or end of
+the line.
+.It Ic x D dd
+Delete the character under the cursor, to end of line, or the whole line;
+.Ic d
+also takes a motion
+.Pq Ic dw , de , db , d$ , d0 .
+.It Ic j / k
+Recall the next or previous prompt sent this session.
+.It Ic J / K
+Scroll the transcript down or up one line.
+.El
+.Sh ENVIRONMENT
+.Bl -tag -width FUGU_CONF
+.It Ev HOME
+Location of the session directory
+.Pa ~/.fugu/sessions .
+.It Ev FUGU_CONF
+Alternate path to the configuration file, for development and the test suite.
+Honoured only when
+.Nm
+is
+.Em not
+installed set-group-id
+.Pq Xr issetugid 2 ;
+an installed
+.Nm
+ignores it and always reads
+.Pa /etc/fugu.conf ,
+so it cannot be steered onto another
+.Sy _fugu Ns -readable
+file.
+.El
+.Sh FILES
+.Bl -tag -width "~/.fugu/sessions/XX"
+.It Pa /etc/fugu.conf
+Configuration and secrets; mode
+.Ar 0640 ,
+owner
+.Sy root ,
+group
+.Sy _fugu
+(see
+.Xr fugu.conf 5 ) .
+.It Pa ~/.fugu/sessions/
+Append-only session journals, one
+.Pa <id>.jsonl
+per session.
+.It Pa ~/.fugu/skills/
+Slash-invoked prompt templates.
+Each
+.Pa <name>/SKILL.md
+is a markdown file with an optional
+.Sq ---
+fenced header carrying
+.Sq name
+and
+.Sq description
+lines; the body below the closing fence is what the model sees when the user
+types
+.Sy / Ns Ar name .
+Loaded once at startup; create or edit files between sessions.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh SEE ALSO
+.Xr ksh 1 ,
+.Xr pledge 2 ,
+.Xr unveil 2 ,
+.Xr imsg 3 ,
+.Xr fugu.conf 5
+.Sh STANDARDS
+.Nm
+speaks the Anthropic Messages API with streaming server-sent events.
+.Sh HISTORY
+.Nm
+is a clean-room reimplementation, in behavior rather than source, of the
+TypeScript agent
+.Em pi
+by Mario Zechner.
+.Sh AUTHORS
+.An Isaac Aq Mt isaac@itm.works .
+.Sh CAVEATS
+.Nm
+runs tools and shell commands chosen by a language model.
+Although
+.Xr pledge 2
+and
+.Xr unveil 2
+confine those tools to the working tree and deny them the network, the model
+can still modify, delete, or read any file beneath the directory in which
+.Nm
+is started.
+Run it from the project you intend it to change, and review what it does.
+In particular, when started from within your home directory the tools can read
+.Pa ~/.fugu/sessions ,
+so the model may see other sessions' transcripts; the configuration secrets,
+however, are not reachable
+.Pq they live in the Sy _fugu Ns -only Pa /etc/fugu.conf .
+.Pp
+By default subprocesses started by the shell tool have no network access;
+set
+.Ic allow_subprocess_net
+in
+.Xr fugu.conf 5
+to permit it.
blob - /dev/null
blob + eddfbc12ede46945df2f202c02619486e18a086d (mode 644)
--- /dev/null
+++ man/fugu.conf.5
+.\" Copyright (c) 2026 Isaac <isaac@itm.works>
+.\"
+.\" Permission to use, copy, modify, and distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd June 21, 2026
+.Dt FUGU.CONF 5
+.Os
+.Sh NAME
+.Nm fugu.conf
+.Nd configuration file for fugu
+.Sh DESCRIPTION
+.Xr fugu 1
+reads all of its configuration, including the secrets, from a single file:
+.Pp
+.Bl -tag -width "/etc/fugu.confXX" -compact
+.It Pa /etc/fugu.conf
+Owned by
+.Sy root ,
+group
+.Sy _fugu ,
+mode
+.Ar 0640 .
+.El
+.Pp
+Because
+.Xr fugu 1
+is installed set-group-id
+.Sy _fugu ,
+the parent can read this root-owned file while the model's file and shell tools,
+which run as the invoking user without that group, cannot
+.Pq see Sx FILES and the Sx CAVEATS in Xr fugu 1 .
+Secrets
+.Po
+.Ic api_key ,
+.Ic anthropic_key ,
+.Ic kagi_token
+.Pc
+therefore live here too, administered centrally rather than in each user's home.
+.Pp
+Effective settings are resolved low to high precedence: compiled defaults, the
+global section, then the first matching
+.Ic match
+block.
+The merged result for the invoking user can be printed with
+.Ic fugu -n ,
+which redacts secret values.
+.Pp
+The file holds secrets, so the parent refuses to start if it is
+world-accessible or group-writable, or owned by anyone but
+.Sy root
+or the invoking user.
+For development and the test suite, a binary that is
+.Em not
+set-group-id honours the
+.Ev FUGU_CONF
+environment variable as the path to read instead
+.Pq see Xr fugu 1 ;
+an installed, set-group-id
+.Xr fugu 1
+ignores the environment so it cannot be steered onto another
+.Sy _fugu Ns -readable
+file.
+.Sh SYNTAX
+The grammar follows the
+.Xr pf.conf 5 Ns / Ns Xr httpd.conf 5
+family.
+Comments begin with
+.Sq # .
+Argument strings are enclosed in double quotes.
+A boolean is the word
+.Ic yes
+or
+.Ic no .
+Macros may be defined and later expanded with a leading
+.Sq $ :
+.Bd -literal -offset indent
+key = "value"
+anthropic_key $key
+.Ed
+.Pp
+Other files may be pulled in with
+.Ic include Ar path .
+.Sh SETTINGS
+The following keywords may appear in the global section or inside a
+.Ic match
+block:
+.Bl -tag -width Ds
+.It Ic model Ar string
+The model id to use, as named by the configured
+.Ic provider .
+Defaults to
+.Qq claude-sonnet-4-6 ,
+the default Anthropic model.
+.It Ic provider Ar string
+The API wire protocol to speak, one of
+.Qq anthropic
+.Pq the default
+or
+.Qq openai .
+The
+.Qq openai
+provider speaks the OpenAI
+.Pa /v1/chat/completions
+API that most other vendors and local servers emulate
+.Pq OpenRouter , Groq , Together , DeepSeek , and Ollama , llama.cpp , vLLM ,
+authenticating with
+.Ic api_key
+as a
+.Ar Bearer
+token.
+Set
+.Ic api_host
+to the vendor's host
+.Pq and usually a
+.Ic model
+and
+.Ic api_key
+to match .
+.It Ic system Ar string
+System prompt sent to the model on every turn, used to set the assistant's
+role, conventions, or constraints.
+Unset by default, in which case fugu sends a concise built-in default prompt;
+set this to replace that default entirely.
+The value is a single quoted string
+.Pq as with any string setting, an embedded literal newline is not preserved ,
+so write a multi-sentence prompt as one line.
+.It Ic max_tokens Ar number
+Maximum number of tokens the model may generate per turn.
+.It Ic context_limit Ar number
+Advisory token budget for the conversation projection sent to the model.
+.It Ic allow_write Ar yes Ns | Ns Ar no
+Whether the file tools may modify the working tree.
+Default
+.Ic yes .
+.It Ic allow_subprocess_net Ar yes Ns | Ns Ar no
+Whether commands run by the shell tool may use the network.
+Default
+.Ic no :
+the exec process and its children are pledged without
+.Ar inet ,
+so network-using commands
+.Po Xr ftp 1 , Xr nc 1 , git fetch Pc
+fail by design until this is enabled.
+.It Ic web_search Ar yes Ns | Ns Ar no
+Whether the brokered
+.Ic web_search
+and
+.Ic web_fetch
+tools are offered to the model.
+Default
+.Ic yes ;
+set
+.Ic no
+to withhold both tools, so the model cannot reach the network at all.
+.It Ic http_allow Ar string
+A whitespace-separated list of host patterns the model may reach with the
+general
+.Ic http_request
+tool, which makes an arbitrary-method HTTPS request with model-supplied headers
+and body.
+The tool is offered to the model
+.Sy only
+when this list is set; with no
+.Ic http_allow
+it does not exist.
+A request is permitted when its host matches at least one
+.Ic http_allow
+pattern and no
+.Ic http_block
+pattern.
+Each pattern is
+.Ar host Ns Op / Ns Ar pathprefix :
+.Bl -bullet -compact -offset indent
+.It
+.Qq example.com
+matches that host exactly;
+.It
+.Qq .example.com
+matches
+.Qq example.com
+and any subdomain of it;
+.It
+an optional
+.Pa / Ns Ar pathprefix
+further requires the request path to begin with it
+.Pq Qq api.example.com/v1 .
+.El
+Host matching is case-insensitive.
+fugu injects no credentials of its own, so the model can only reach hosts the
+operator already lists, using whatever headers it supplies; the
+.Ic web_fetch
+SSRF guard
+.Pq private, loopback and link-local addresses refused
+also applies.
+.It Ic http_block Ar string
+A whitespace-separated list of patterns, in the same form as
+.Ic http_allow ,
+that are denied even when an
+.Ic http_allow
+pattern would otherwise permit them
+.Pq a block wins .
+.It Ic search_provider Ar string
+Name of the web search provider.
+.It Ic ascii Ar yes Ns | Ns Ar no
+Render the terminal interface transliterated to ASCII instead of UTF-8.
+Default
+.Ic no .
+.It Ic api_host Ar string
+Hostname of the model API endpoint, overriding the provider's built-in default
+.Po
+.Qq api.anthropic.com
+or
+.Qq api.openai.com
+.Pc .
+Required for third-party
+.Qq openai
+endpoints
+.Pq e.g. Qq openrouter.ai .
+.It Ic api_port Ar string
+TCP port of the model API endpoint.
+Defaults to
+.Qq 443 .
+fugu connects over TLS only, so the endpoint must speak HTTPS.
+.It Ic api_path Ar string
+Request path of the chat/completions endpoint, overriding the provider's
+default
+.Po
+.Qq /v1/messages
+or
+.Qq /v1/chat/completions
+.Pc .
+Needed for OpenAI-compatible vendors that do not serve at
+.Qq /v1 ,
+for example
+.Qq /api/v1/chat/completions
+.Pq OpenRouter
+or
+.Qq /openai/v1/chat/completions
+.Pq Groq .
+.It Ic api_key Ar string
+The model API key, used for any provider.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+For the
+.Qq anthropic
+provider the legacy
+.Ic anthropic_key
+is an accepted synonym;
+.Ic api_key
+takes precedence when both are set.
+.It Ic api_key_file Ar string
+Path to a file whose first line is the model API key, used instead of
+.Ic api_key .
+.It Ic anthropic_key Ar string
+The Anthropic API key; a legacy synonym for
+.Ic api_key
+kept for compatibility.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+.It Ic anthropic_key_file Ar string
+Path to a file whose first line is the Anthropic API key, used instead of
+.Ic anthropic_key .
+.It Ic kagi_token Ar string
+The Kagi API token used by the web tools.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+.It Ic kagi_token_file Ar string
+Path to a file whose first line is the Kagi token, used instead of
+.Ic kagi_token .
+.El
+.Sh MATCH BLOCKS
+.Pa /etc/fugu.conf
+may scope settings to particular users or groups with first-match-wins
+.Ic match
+blocks, in the style of
+.Xr ssh_config 5 Ns 's
+.Ic Match :
+.Bd -literal -offset indent
+match user "alice" {
+ model "claude-opus-4-8"
+ allow_subprocess_net yes
+}
+
+match group "students" {
+ allow_write no
+}
+.Ed
+.Pp
+The first block whose
+.Ic user
+or
+.Ic group
+matches the invoking user supplies the scoped settings; later blocks are not
+consulted.
+Secrets may not appear in a
+.Ic match
+block, as they may not appear anywhere in
+.Pa /etc/fugu.conf .
+.Sh PROVIDER BLOCKS
+The flat
+.Ic provider , model , api_key , api_host , api_port
+and
+.Ic api_path
+settings above configure a single, default provider.
+To make more than one provider available at once \(en a direct Anthropic key
+.Em and
+an OpenAI-compatible endpoint, say \(en give each additional one a named
+.Ic provider
+block:
+.Bd -literal -offset indent
+provider "name" {
+ type "anthropic" | "openai"
+ model string
+ api_key string
+ api_key_file string
+ api_host string
+ api_port string
+ api_path string
+}
+.Ed
+.Pp
+.Ic type
+selects the wire protocol
+.Pq it is the in-block spelling of the flat Ic provider No keyword ;
+the remaining keys mean exactly what they do at the top level, but scoped to
+this provider.
+The flat top-level keys still define an implicit
+.Qq default
+provider, so existing single-provider configurations are unchanged; each
+.Ic provider
+block adds one more.
+At startup the default provider (or, if none is configured, the first block) is
+active; the
+.Ic /model
+command
+.Pq see Xr fugu 1
+lists the models of every configured provider, tagged by name, and switching to
+one also switches the active provider for the rest of the session.
+A block's
+.Ic api_key
+is a secret like any other and lives in the same root-owned
+.Pa /etc/fugu.conf ;
+.Ic api_key_file
+may point at a separate file the
+.Sy _fugu
+group can read instead.
+.Sh FILES
+.Bl -tag -width "/etc/fugu.conf.sampleXX" -compact
+.It Pa /etc/fugu.conf
+The configuration and secrets; mode
+.Ar 0640 ,
+owner
+.Sy root ,
+group
+.Sy _fugu .
+.It Pa /etc/fugu.conf.sample
+Annotated example installed with the package.
+.El
+.Sh EXAMPLES
+A minimal
+.Pa /etc/fugu.conf
+.Pq Sy root : Ns Sy _fugu No mode Ar 0640 :
+.Bd -literal -offset indent
+anthropic_key "sk-ant-..."
+model "claude-sonnet-4-6"
+system "You are a terse assistant for an OpenBSD C codebase. \e
+Follow KNF, prefer base over ports, and explain risky changes."
+.Ed
+.Pp
+Reading the key from a separate file, and enabling the web tools:
+.Bd -literal -offset indent
+anthropic_key_file "/etc/fugu/anthropic.key"
+kagi_token_file "/etc/fugu/kagi.token"
+web_search yes
+.Ed
+.Pp
+Using an OpenAI-compatible provider
+.Pq here OpenRouter , whose endpoint is not at Qq /v1 :
+.Bd -literal -offset indent
+provider "openai"
+api_host "openrouter.ai"
+api_path "/api/v1/chat/completions"
+model "anthropic/claude-sonnet-4.6"
+api_key "sk-or-..."
+.Ed
+.Pp
+Groq's free tier
+.Pq endpoint at Qq /openai/v1 :
+.Bd -literal -offset indent
+provider "openai"
+api_host "api.groq.com"
+api_path "/openai/v1/chat/completions"
+model "llama-3.3-70b-versatile"
+api_key "gsk-..."
+.Ed
+.Pp
+Two providers at once: a direct Anthropic key as the default, plus a named
+OpenAI provider.
+.Ic /model
+then lists and switches between both.
+.Bd -literal -offset indent
+anthropic_key "sk-ant-..."
+model "claude-sonnet-4-6"
+
+provider "gpt" {
+ type "openai"
+ api_key_file "/etc/fugu/openai.key"
+ model "gpt-5"
+}
+.Ed
+.Sh SEE ALSO
+.Xr fugu 1
+.Sh CAVEATS
+Because
+.Pa /etc/fugu.conf
+holds secrets, a file that is world-accessible, group-writable, or owned by
+neither
+.Sy root
+nor the invoking user is a fatal error rather than a warning:
+.Xr fugu 1
+refuses to start until it is corrected.
blob - /dev/null
blob + 5afa5d62906884cb805efc418be4b2e7d2d8094f (mode 644)
--- /dev/null
+++ regress/anthropic/Makefile
+# Unit tests for the Anthropic provider layer (src/common/anthropic.c).
+
+PROG= anthropic_test
+SRCS= anthropic_test.c anthropic.c sse.c json.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-anthropic_test
+
+run-anthropic_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + d0a75de6bd444fcf0a156298a3b1a10b2c20737d (mode 644)
--- /dev/null
+++ regress/anthropic/anthropic_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "sse.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* ---- callback recorder ---- */
+
+static struct buf rec_text;
+static char *rec_tool_id, *rec_tool_name, *rec_tool_input;
+static int rec_tools;
+static long long rec_in, rec_out;
+static int rec_usage;
+static char *rec_stop;
+static int rec_done;
+static char *rec_err_type, *rec_err_msg;
+static int rec_errors;
+
+static void
+rec_reset(void)
+{
+ buf_reset(&rec_text);
+ free(rec_tool_id); rec_tool_id = NULL;
+ free(rec_tool_name); rec_tool_name = NULL;
+ free(rec_tool_input); rec_tool_input = NULL;
+ rec_tools = 0;
+ rec_in = rec_out = 0;
+ rec_usage = 0;
+ free(rec_stop); rec_stop = NULL;
+ rec_done = 0;
+ free(rec_err_type); rec_err_type = NULL;
+ free(rec_err_msg); rec_err_msg = NULL;
+ rec_errors = 0;
+}
+
+static void
+cb_text(const char *t, size_t n, void *a)
+{
+ (void)a;
+ buf_append(&rec_text, t, n);
+}
+
+static void
+cb_tool(const char *id, const char *name, const char *input, size_t n, void *a)
+{
+ (void)a;
+ free(rec_tool_id); rec_tool_id = strdup(id);
+ free(rec_tool_name); rec_tool_name = strdup(name);
+ free(rec_tool_input);
+ rec_tool_input = malloc(n + 1);
+ memcpy(rec_tool_input, input, n);
+ rec_tool_input[n] = '\0';
+ rec_tools++;
+}
+
+static void
+cb_usage(long long in, long long out, void *a)
+{
+ (void)a;
+ rec_in = in;
+ rec_out = out;
+ rec_usage++;
+}
+
+static void
+cb_stop(const char *sr, void *a)
+{
+ (void)a;
+ free(rec_stop);
+ rec_stop = strdup(sr);
+}
+
+static void
+cb_error(const char *type, const char *msg, void *a)
+{
+ (void)a;
+ free(rec_err_type); rec_err_type = strdup(type);
+ free(rec_err_msg); rec_err_msg = strdup(msg);
+ rec_errors++;
+}
+
+static void
+cb_done(void *a)
+{
+ (void)a;
+ rec_done++;
+}
+
+static const struct llm_cbs RECORDER = {
+ cb_text, cb_tool, cb_usage, cb_stop, cb_error, cb_done
+};
+
+static void
+ev(struct anthropic_stream *s, const char *event, const char *data)
+{
+ anthropic_stream_event(s, event, data, strlen(data));
+}
+
+/* ---- request builder ---- */
+
+static void
+test_build_basic(void)
+{
+ struct buf out;
+ struct anth_msg msgs[] = {
+ { "user", "hello" },
+ { "assistant", "hi there" },
+ };
+ const char *expect =
+ "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":1024,"
+ "\"stream\":true,\"messages\":[{\"role\":\"user\","
+ "\"content\":\"hello\"},{\"role\":\"assistant\","
+ "\"content\":\"hi there\"}]}";
+
+ buf_init(&out);
+ anthropic_build_request(&out, "claude-sonnet-4-6", 1024, NULL,
+ msgs, 2, NULL);
+ buf_terminate(&out);
+ CHECK(strcmp(out.data, expect) == 0, "build basic:\n%s", out.data);
+ buf_free(&out);
+}
+
+static void
+test_build_system_tools(void)
+{
+ struct buf out;
+ struct anth_msg msgs[] = { { "user", "hi" } };
+ const char *expect =
+ "{\"model\":\"m\",\"max_tokens\":8,\"stream\":true,"
+ "\"system\":\"be brief\",\"messages\":[{\"role\":\"user\","
+ "\"content\":\"hi\"}],\"tools\":[{\"name\":\"read\"}]}";
+
+ buf_init(&out);
+ anthropic_build_request(&out, "m", 8, "be brief", msgs, 1,
+ "[{\"name\":\"read\"}]");
+ buf_terminate(&out);
+ CHECK(strcmp(out.data, expect) == 0, "build sys+tools:\n%s", out.data);
+ buf_free(&out);
+}
+
+static void
+test_build_escaping(void)
+{
+ struct buf out;
+ struct anth_msg msgs[] = { { "user", "quote \" and \\ and\nnewline" } };
+ const char *expect =
+ "{\"model\":\"m\",\"max_tokens\":1,\"stream\":true,"
+ "\"messages\":[{\"role\":\"user\","
+ "\"content\":\"quote \\\" and \\\\ and\\nnewline\"}]}";
+
+ buf_init(&out);
+ anthropic_build_request(&out, "m", 1, NULL, msgs, 1, NULL);
+ buf_terminate(&out);
+ CHECK(strcmp(out.data, expect) == 0, "build escaping:\n%s", out.data);
+ buf_free(&out);
+}
+
+/* ---- streaming event interpreter ---- */
+
+static void
+test_text_stream(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "content_block_start",
+ "{\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}");
+ ev(&s, "content_block_delta",
+ "{\"type\":\"content_block_delta\",\"index\":0,"
+ "\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}");
+ ev(&s, "content_block_delta",
+ "{\"type\":\"content_block_delta\",\"index\":0,"
+ "\"delta\":{\"type\":\"text_delta\",\"text\":\", world\"}}");
+ ev(&s, "content_block_stop",
+ "{\"type\":\"content_block_stop\",\"index\":0}");
+ buf_terminate(&rec_text);
+ CHECK(strcmp(rec_text.data, "Hello, world") == 0, "text stream: '%s'",
+ rec_text.data);
+ CHECK(rec_tools == 0, "text stream: no tool call");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_call(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "content_block_start",
+ "{\"type\":\"content_block_start\",\"index\":1,"
+ "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\","
+ "\"name\":\"get_weather\",\"input\":{}}}");
+ ev(&s, "content_block_delta",
+ "{\"type\":\"content_block_delta\",\"index\":1,"
+ "\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"{\\\"x\\\"\"}}");
+ ev(&s, "content_block_delta",
+ "{\"type\":\"content_block_delta\",\"index\":1,"
+ "\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\":42}\"}}");
+ ev(&s, "content_block_stop",
+ "{\"type\":\"content_block_stop\",\"index\":1}");
+ CHECK(rec_tools == 1, "tool: one call (%d)", rec_tools);
+ CHECK(rec_tool_id && strcmp(rec_tool_id, "toolu_1") == 0, "tool: id");
+ CHECK(rec_tool_name && strcmp(rec_tool_name, "get_weather") == 0,
+ "tool: name");
+ CHECK(rec_tool_input && strcmp(rec_tool_input, "{\"x\":42}") == 0,
+ "tool: input '%s'", rec_tool_input ? rec_tool_input : "(null)");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_no_args(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "content_block_start",
+ "{\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"tool_use\",\"id\":\"t2\","
+ "\"name\":\"now\",\"input\":{}}}");
+ ev(&s, "content_block_stop",
+ "{\"type\":\"content_block_stop\",\"index\":0}");
+ CHECK(rec_tools == 1 && rec_tool_input &&
+ strcmp(rec_tool_input, "{}") == 0, "tool no-args input '%s'",
+ rec_tool_input ? rec_tool_input : "(null)");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_usage_stop_done(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "message_start",
+ "{\"type\":\"message_start\",\"message\":{\"id\":\"m\","
+ "\"role\":\"assistant\",\"usage\":{\"input_tokens\":10,"
+ "\"output_tokens\":1}}}");
+ CHECK(rec_usage == 1 && rec_in == 10 && rec_out == 1,
+ "usage start: in=%lld out=%lld", rec_in, rec_out);
+ ev(&s, "message_delta",
+ "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\",\"stop_sequence\":null},"
+ "\"usage\":{\"output_tokens\":25}}");
+ CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "stop reason");
+ CHECK(rec_in == 10 && rec_out == 25, "usage delta: in=%lld out=%lld",
+ rec_in, rec_out);
+ ev(&s, "message_stop", "{\"type\":\"message_stop\"}");
+ CHECK(rec_done == 1, "done");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_null_stop_reason(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "message_delta",
+ "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null}}");
+ CHECK(rec_stop == NULL, "null stop_reason not reported");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_error_event(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "error",
+ "{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\","
+ "\"message\":\"slow down\"}}");
+ CHECK(rec_errors == 1 && rec_err_type &&
+ strcmp(rec_err_type, "overloaded_error") == 0, "error type");
+ CHECK(rec_err_msg && strcmp(rec_err_msg, "slow down") == 0, "error msg");
+ /* after a fatal error, further events are ignored */
+ ev(&s, "content_block_delta",
+ "{\"type\":\"content_block_delta\",\"index\":0,"
+ "\"delta\":{\"type\":\"text_delta\",\"text\":\"ignored\"}}");
+ CHECK(rec_text.len == 0, "post-error events ignored");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_ping_and_badjson(void)
+{
+ struct anthropic_stream s;
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "ping", "{\"type\":\"ping\"}");
+ CHECK(rec_errors == 0 && rec_done == 0 && rec_text.len == 0,
+ "ping ignored");
+
+ ev(&s, "content_block_delta", "{"); /* malformed JSON */
+ CHECK(rec_errors == 1 && rec_err_type &&
+ strcmp(rec_err_type, "parse_error") == 0, "bad json -> parse_error");
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+/* ---- integration: SSE wire -> sse -> anthropic ---- */
+
+static void
+sse_trampoline(const char *event, const char *data, size_t datalen, void *arg)
+{
+ anthropic_stream_event(arg, event, data, datalen);
+}
+
+static void
+test_integration(void)
+{
+ struct anthropic_stream s;
+ struct sse_parser p;
+ const char *wire =
+ "event: message_start\n"
+ "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+ "{\"input_tokens\":7,\"output_tokens\":1}}}\n"
+ "\n"
+ "event: content_block_start\n"
+ "data: {\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"It is \"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"sunny.\"}}\n"
+ "\n"
+ "event: content_block_stop\n"
+ "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+ "\n"
+ "event: message_delta\n"
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\"},\"usage\":{\"output_tokens\":9}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n";
+ size_t i, len = strlen(wire);
+
+ anthropic_stream_init(&s, &RECORDER, NULL);
+ sse_init(&p, sse_trampoline, &s);
+
+ /* feed the wire in 7-byte network-sized pieces */
+ for (i = 0; i < len; i += 7) {
+ size_t n = (len - i < 7) ? len - i : 7;
+ if (sse_push(&p, wire + i, n) != 0)
+ failures++;
+ }
+
+ buf_terminate(&rec_text);
+ CHECK(strcmp(rec_text.data, "It is sunny.") == 0,
+ "integration text: '%s'", rec_text.data);
+ CHECK(rec_in == 7 && rec_out == 9, "integration usage in=%lld out=%lld",
+ rec_in, rec_out);
+ CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "integration stop");
+ CHECK(rec_done == 1, "integration done");
+
+ sse_clear(&p);
+ anthropic_stream_clear(&s);
+ rec_reset();
+}
+
+int
+main(void)
+{
+ buf_init(&rec_text);
+
+ test_build_basic();
+ test_build_system_tools();
+ test_build_escaping();
+ test_text_stream();
+ test_tool_call();
+ test_tool_no_args();
+ test_usage_stop_done();
+ test_null_stop_reason();
+ test_error_event();
+ test_ping_and_badjson();
+ test_integration();
+
+ buf_free(&rec_text);
+ printf("anthropic_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 171364dcfe0b3bf4079a31790fb59c57bf44d39a (mode 644)
--- /dev/null
+++ regress/buf/Makefile
+# Unit tests for the growable byte-buffer module (src/common/buf.c).
+# Covers the post-audit-hardening contract: buf_terminate writes a NUL but does
+# not count it in .len, and buf_cstr's debug-assert catches the missing-NUL bug
+# at the read site.
+
+PROG= buf_test
+SRCS= buf_test.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-buf_test
+
+run-buf_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 84c4a5a1b90eafca48034526d47f0ecd922b739d (mode 644)
--- /dev/null
+++ regress/buf/buf_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the buf module, focused on the post-hardening contract:
+ *
+ * - buf_terminate(b) writes a '\0' at b->data[b->len] but leaves .len
+ * pointing at the last logical byte (so strlen(b->data) == b->len).
+ * - buf_cstr(b) reads .data as a C string and asserts the trailing NUL is
+ * there; empty buf returns "".
+ * - buf_append + buf_terminate round-trips bytes including embedded NULs
+ * (the NUL appears IN the buffer length-prefixed; the trailing NUL is
+ * the C-string terminator, not part of the payload).
+ *
+ * The original bug shape -- buf_puts (now buf_puts_cstr) writes via strlen,
+ * leaving b->data[b->len] uninitialized -- is documented with a test that
+ * shows the byte at .len IS unset after just buf_puts_cstr, then becomes
+ * '\0' after buf_terminate.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static void
+test_terminate_basic(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_append(&b, "hello", 5);
+ buf_terminate(&b);
+ CHECK(b.len == 5, "buf_terminate keeps len at logical bytes (got %zu)",
+ b.len);
+ CHECK(b.data[b.len] == '\0',
+ "buf_terminate writes a NUL at b.data[b.len]");
+ CHECK(strlen(b.data) == 5,
+ "strlen(b.data) matches b.len (got %zu)", strlen(b.data));
+ CHECK(strcmp(b.data, "hello") == 0, "payload intact after terminate");
+ buf_free(&b);
+}
+
+static void
+test_terminate_empty(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_terminate(&b);
+ CHECK(b.len == 0, "empty + terminate stays at len 0 (got %zu)", b.len);
+ CHECK(b.data != NULL && b.data[0] == '\0',
+ "empty + terminate writes NUL at position 0");
+ buf_free(&b);
+}
+
+static void
+test_buf_cstr_empty(void)
+{
+ struct buf b;
+ const char *s;
+
+ buf_init(&b);
+ /* No allocation -- data is still NULL. */
+ s = buf_cstr(&b);
+ CHECK(s != NULL && *s == '\0',
+ "buf_cstr on uninitialised buf returns \"\"");
+ buf_free(&b);
+}
+
+static void
+test_buf_cstr_after_terminate(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_puts_cstr(&b, "abcd");
+ buf_terminate(&b);
+ CHECK(strcmp(buf_cstr(&b), "abcd") == 0,
+ "buf_cstr returns the payload as C string");
+ buf_free(&b);
+}
+
+/*
+ * The original bug: buf_puts_cstr (was buf_puts) writes payload bytes via
+ * strlen but does NOT write a trailing NUL. After only that call,
+ * b->data[b->len] is uninitialised heap. buf_terminate fixes it; buf_cstr
+ * is the read-side contract that catches a missing fix.
+ */
+static void
+test_puts_cstr_does_not_terminate(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_putc(&b, 'X'); /* scribble sentinels at positions 0..2 */
+ buf_putc(&b, 'X');
+ buf_putc(&b, 'X');
+ b.len = 0; /* reset, but the bytes stay in .data */
+
+ buf_puts_cstr(&b, "ab");
+ CHECK(b.len == 2, "buf_puts_cstr sets len to strlen");
+ CHECK(b.data[0] == 'a' && b.data[1] == 'b',
+ "payload bytes written");
+ CHECK(b.data[b.len] == 'X',
+ "b.data[b.len] is the prior sentinel, NOT '\\0' "
+ "(this is the bug class buf_terminate + buf_cstr exists to catch)");
+
+ buf_terminate(&b);
+ CHECK(b.data[b.len] == '\0',
+ "buf_terminate writes '\\0' at b.data[b.len]");
+ CHECK(strcmp(buf_cstr(&b), "ab") == 0,
+ "buf_cstr round-trips after buf_terminate");
+ buf_free(&b);
+}
+
+/*
+ * Embedded NULs survive buf_append + buf_terminate: the payload is
+ * length-counted in .len, the trailing terminator is just for safe C-string
+ * read-back of the AROUND data (it's at b.data[b.len], past the payload).
+ */
+static void
+test_embedded_nul_roundtrip(void)
+{
+ struct buf b;
+ const char *payload = "ab\0cd"; /* length 5 incl. NUL */
+ size_t i;
+ int bytes_match = 1;
+
+ buf_init(&b);
+ buf_append(&b, payload, 5);
+ buf_terminate(&b);
+ CHECK(b.len == 5,
+ "buf_append + buf_terminate preserves length-5 payload "
+ "(got %zu)", b.len);
+ for (i = 0; i < 5; i++)
+ if (b.data[i] != payload[i])
+ bytes_match = 0;
+ CHECK(bytes_match, "bytes (including embedded NUL) match");
+ CHECK(b.data[5] == '\0', "trailing NUL after the payload");
+ /*
+ * strlen would return 2 here (it stops at the embedded NUL); that is
+ * exactly why buf_cstr is documented as dispatch-only when the buf
+ * may carry NULs, and length-aware readers should use .len directly.
+ */
+ CHECK(strlen(b.data) == 2, "strlen sees only the first NUL");
+ buf_free(&b);
+}
+
+/*
+ * buf_terminate is idempotent in the sense that calling it twice still
+ * leaves b.data[b.len] == '\0' and b.len unchanged -- two calls are
+ * surprising but should not corrupt state. Catches a future regression
+ * that, say, makes buf_terminate forget to decrement .len.
+ */
+static void
+test_terminate_idempotent(void)
+{
+ struct buf b;
+ size_t len_before;
+
+ buf_init(&b);
+ buf_append(&b, "xy", 2);
+ buf_terminate(&b);
+ len_before = b.len;
+ buf_terminate(&b);
+ CHECK(b.len == len_before, "second buf_terminate keeps .len");
+ CHECK(b.data[b.len] == '\0', "trailing NUL still in place");
+ CHECK(strcmp(buf_cstr(&b), "xy") == 0,
+ "buf_cstr still round-trips");
+ buf_free(&b);
+}
+
+/*
+ * buf_freezero should still work after buf_terminate, and the data buffer
+ * should be cleared to all zeros up to .cap before free. We can only
+ * observe the pre-free state -- after free the pointer is dangling -- so
+ * verify by reading the bytes back via b.data (set to NULL by freezero).
+ */
+static void
+test_freezero_after_terminate(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_append(&b, "secret", 6);
+ buf_terminate(&b);
+ buf_freezero(&b);
+ CHECK(b.data == NULL, "buf_freezero NULLs the data pointer");
+ CHECK(b.len == 0, "buf_freezero zeros the length");
+ CHECK(b.cap == 0, "buf_freezero zeros the capacity");
+}
+
+int
+main(void)
+{
+ test_terminate_basic();
+ test_terminate_empty();
+ test_buf_cstr_empty();
+ test_buf_cstr_after_terminate();
+ test_puts_cstr_does_not_terminate();
+ test_embedded_nul_roundtrip();
+ test_terminate_idempotent();
+ test_freezero_after_terminate();
+
+ printf("buf_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 581967c540081b97e030687f396e67eab011aa41 (mode 644)
--- /dev/null
+++ regress/chat/Makefile
+# Tracer-bullet end-to-end test: run fugu(1) chatting with a libtls stub server.
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG= chat_test
+SRCS= chat_test.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN= ${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN= ${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN= ${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN= ${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN= ${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS= run-chat_test
+
+run-chat_test: ${PROG}
+ @td=`mktemp -d /tmp/fugu-chat-cert.XXXXXX`; \
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout $$td/key.pem -out $$td/cert.pem \
+ -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+ FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+ FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+ FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+ ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+ rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + e1ae12a6ec0886e822d476c1a5790cde4d563aeb (mode 644)
--- /dev/null
+++ regress/chat/chat_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The tracer bullet, end to end: run the real `fugu` binary (which spawns
+ * fugu-ui and fugu-net), feed a line to its stdin, and read the streamed reply
+ * from its stdout. net is pointed at a libtls stub server standing in for
+ * api.anthropic.com, so no credentials or outbound network are needed.
+ *
+ * Requires FUGU_BIN / FUGU_UI_BIN / FUGU_NET_BIN in the environment (set by the
+ * Makefile) and a cert/key pair in argv (generated by the Makefile).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int checks;
+static int failures;
+
+/* Remove the session journals the run leaves behind, then the directory. */
+static void
+rm_sessions(const char *home)
+{
+ char path[1100], f[1200];
+ DIR *d;
+ struct dirent *e;
+
+ snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+ if ((d = opendir(path)) != NULL) {
+ while ((e = readdir(d)) != NULL) {
+ if (e->d_name[0] == '.')
+ continue;
+ snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+ unlink(f);
+ }
+ closedir(d);
+ }
+ rmdir(path);
+}
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static const char *const SSE_BODY =
+ "event: content_block_start\n"
+ "data: {\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"pong\"}}\n"
+ "\n"
+ "event: content_block_stop\n"
+ "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+ "\n"
+ "event: message_delta\n"
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n";
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+ buf_printf(b, "%zx\r\n", n);
+ buf_append(b, data, n);
+ buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tls_write(t, p + off, len - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n < 0)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+ struct tls_config *cfg;
+ struct tls *srv, *cctx;
+ struct buf resp;
+ char req[2048];
+ ssize_t n;
+ int cfd;
+ size_t sl, half;
+
+ if ((cfd = accept(lfd, NULL, NULL)) == -1)
+ _exit(20);
+ if ((cfg = tls_config_new()) == NULL)
+ _exit(21);
+ if (tls_config_set_cert_file(cfg, cert) == -1 ||
+ tls_config_set_key_file(cfg, key) == -1)
+ _exit(22);
+ if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+ _exit(23);
+ if (tls_accept_socket(srv, &cctx, cfd) == -1)
+ _exit(24);
+ do {
+ n = tls_handshake(cctx);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n == -1)
+ _exit(25);
+ do {
+ n = tls_read(cctx, req, sizeof(req));
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+ buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+ sl = strlen(SSE_BODY);
+ half = sl / 2;
+ add_chunk(&resp, SSE_BODY, half);
+ add_chunk(&resp, SSE_BODY + half, sl - half);
+ buf_puts_cstr(&resp, "0\r\n\r\n");
+ (void)tls_write_all(cctx, resp.data, resp.len);
+ buf_free(&resp);
+ tls_close(cctx);
+ _exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+ char dir[1024], path[1024];
+ FILE *fp;
+ int fd;
+
+ snprintf(dir, sizeof(dir), "%s/.fugu", home);
+ if (mkdir(dir, 0700) == -1)
+ err(1, "mkdir %s", dir);
+ snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+ if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+ err(1, "open %s", path);
+ if (fchmod(fd, 0600) == -1) /* defeat a permissive umask */
+ err(1, "fchmod");
+ if ((fp = fdopen(fd, "w")) == NULL)
+ err(1, "fdopen");
+ fputs("anthropic_key \"test-key\"\n", fp);
+ fputs("model \"claude-sonnet-4-6\"\n", fp);
+ fputs("max_tokens 64\n", fp);
+ fclose(fp);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "chat_test: TIMED OUT\n");
+ _exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct sockaddr_in sa;
+ socklen_t slen;
+ pid_t sp, fp;
+ const char *fugu_bin;
+ char home[] = "/tmp/fugu-chat.XXXXXX";
+ char out[8192], portstr[16];
+ size_t outlen = 0;
+ ssize_t k;
+ int lfd, one = 1, port, status;
+ int in_pipe[2], out_pipe[2];
+
+ if (argc != 3)
+ errx(1, "usage: %s cert.pem key.pem", argv[0]);
+ if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+ errx(1, "FUGU_BIN not set");
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(90);
+
+ if (mkdtemp(home) == NULL)
+ err(1, "mkdtemp");
+ write_config(home);
+
+ /* stub-server TCP listener on an ephemeral 127.0.0.1 port */
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 1) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if ((sp = fork()) == -1)
+ err(1, "fork");
+ if (sp == 0) {
+ stub_server(lfd, argv[1], argv[2]);
+ _exit(0);
+ }
+ close(lfd);
+
+ if (pipe(in_pipe) == -1 || pipe(out_pipe) == -1)
+ err(1, "pipe");
+
+ if ((fp = fork()) == -1)
+ err(1, "fork");
+ if (fp == 0) {
+ dup2(in_pipe[0], STDIN_FILENO);
+ dup2(out_pipe[1], STDOUT_FILENO);
+ close(in_pipe[0]);
+ close(in_pipe[1]);
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+ setenv("HOME", home, 1);
+ { /* not setgid in the harness, so FUGU_CONF is honoured */
+ char confpath[1100];
+
+ snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+ home);
+ setenv("FUGU_CONF", confpath, 1);
+ }
+ setenv("FUGU_NET_HOST", "localhost", 1);
+ setenv("FUGU_NET_PORT", portstr, 1);
+ setenv("FUGU_NET_CAFILE", argv[1], 1);
+ execl(fugu_bin, fugu_bin, (char *)NULL);
+ _exit(127);
+ }
+ close(in_pipe[0]);
+ close(out_pipe[1]);
+
+ /* drive the conversation */
+ if (write(in_pipe[1], "ping\n", 5) != 5)
+ CHECK(0, "write to fugu stdin");
+
+ while (outlen < sizeof(out) - 1) {
+ k = read(out_pipe[0], out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ out[outlen] = '\0';
+ if (strstr(out, "pong") != NULL)
+ break;
+ }
+ CHECK(strstr(out, "pong") != NULL, "assistant reply contains 'pong'");
+
+ close(in_pipe[1]); /* EOF -> ui quits -> fugu shuts down */
+ close(out_pipe[0]);
+
+ if (waitpid(fp, &status, 0) == -1)
+ err(1, "waitpid fugu");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "fugu exit status %d", status);
+ if (waitpid(sp, &status, 0) == -1)
+ err(1, "waitpid stub");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "stub server exit %d", status);
+
+ /* best-effort cleanup of the temp HOME tree */
+ {
+ char p[1100];
+
+ snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+ unlink(p);
+ rm_sessions(home);
+ snprintf(p, sizeof(p), "%s/.fugu", home);
+ rmdir(p);
+ rmdir(home);
+ }
+
+ printf("chat_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + fa935eb6a4497a82ce29e2f5545fc9c956f7c825 (mode 644)
--- /dev/null
+++ regress/compact/Makefile
+# End-to-end test of /compact against a libtls stub api.anthropic.com.
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG= compact_test
+SRCS= compact_test.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN= ${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN= ${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN= ${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN= ${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN= ${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS= run-compact_test
+
+run-compact_test: ${PROG}
+ @td=`mktemp -d /tmp/fugu-compact-cert.XXXXXX`; \
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout $$td/key.pem -out $$td/cert.pem \
+ -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+ FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+ FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+ FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+ ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+ rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 38bf6abb445f3e36e9c450325af2af19a4317b52 (mode 644)
--- /dev/null
+++ regress/compact/compact_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * End-to-end test of /compact: run the real fugu (line mode) against a libtls
+ * stub standing in for api.anthropic.com. The stub serves two connections -- a
+ * normal turn ("pong") and then the summarisation call ("Compacted summary.").
+ * We type "ping" then "/compact" and verify the summary is shown and that the
+ * journal records a compact marker carrying the summary text.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* Append a complete single-text-block SSE response with the given text. */
+static void
+make_sse(struct buf *b, const char *text)
+{
+ buf_puts_cstr(b, "event: message_start\n"
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"x\","
+ "\"role\":\"assistant\",\"usage\":{\"input_tokens\":5,"
+ "\"output_tokens\":0}}}\n\n");
+ buf_puts_cstr(b, "event: content_block_start\n"
+ "data: {\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n");
+ buf_printf(b, "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"%s\"}}\n\n", text);
+ buf_puts_cstr(b, "event: content_block_stop\n"
+ "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n");
+ buf_puts_cstr(b, "event: message_delta\n"
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n\n");
+ buf_puts_cstr(b, "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n\n");
+}
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+ buf_printf(b, "%zx\r\n", n);
+ buf_append(b, data, n);
+ buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tls_write(t, p + off, len - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n < 0)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+/* Accept one TLS connection, read the request, and stream `text` as SSE. */
+static void
+serve_one(int lfd, struct tls *srv, const char *text)
+{
+ struct tls *cctx;
+ struct buf body, resp;
+ char req[4096];
+ ssize_t n;
+ int cfd;
+ size_t sl, half;
+
+ if ((cfd = accept(lfd, NULL, NULL)) == -1)
+ _exit(30);
+ if (tls_accept_socket(srv, &cctx, cfd) == -1)
+ _exit(31);
+ do {
+ n = tls_handshake(cctx);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n == -1)
+ _exit(32);
+ do {
+ n = tls_read(cctx, req, sizeof(req));
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+ buf_init(&body);
+ make_sse(&body, text);
+
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+ buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+ sl = body.len;
+ half = sl / 2;
+ add_chunk(&resp, body.data, half);
+ add_chunk(&resp, body.data + half, sl - half);
+ buf_puts_cstr(&resp, "0\r\n\r\n");
+ (void)tls_write_all(cctx, resp.data, resp.len);
+ buf_free(&resp);
+ buf_free(&body);
+ tls_close(cctx);
+ close(cfd);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+ struct tls_config *cfg;
+ struct tls *srv;
+
+ if ((cfg = tls_config_new()) == NULL)
+ _exit(21);
+ if (tls_config_set_cert_file(cfg, cert) == -1 ||
+ tls_config_set_key_file(cfg, key) == -1)
+ _exit(22);
+ if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+ _exit(23);
+
+ serve_one(lfd, srv, "pong"); /* the normal turn */
+ serve_one(lfd, srv, "Compacted summary."); /* the /compact call */
+
+ tls_free(srv);
+ _exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+ char dir[1024], path[1024];
+ FILE *fp;
+ int fd;
+
+ snprintf(dir, sizeof(dir), "%s/.fugu", home);
+ if (mkdir(dir, 0700) == -1)
+ err(1, "mkdir %s", dir);
+ snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+ if (mkdir(dir, 0700) == -1)
+ err(1, "mkdir %s", dir);
+ snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+ if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+ err(1, "open %s", path);
+ if (fchmod(fd, 0600) == -1)
+ err(1, "fchmod");
+ if ((fp = fdopen(fd, "w")) == NULL)
+ err(1, "fdopen");
+ fputs("anthropic_key \"test-key\"\n", fp);
+ fputs("model \"claude-sonnet-4-6\"\n", fp);
+ fputs("max_tokens 64\n", fp);
+ fclose(fp);
+}
+
+/* Read the single session journal in HOME into buf (NUL-terminated). */
+static void
+read_journal(const char *home, char *out, size_t outsz)
+{
+ char dir[1100], path[2300];
+ DIR *d;
+ struct dirent *e;
+ FILE *fp;
+ size_t n = 0;
+
+ out[0] = '\0';
+ snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+ if ((d = opendir(dir)) == NULL)
+ return;
+ while ((e = readdir(d)) != NULL) {
+ if (e->d_name[0] == '.')
+ continue;
+ snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
+ if ((fp = fopen(path, "r")) != NULL) {
+ n += fread(out + n, 1, outsz - 1 - n, fp);
+ fclose(fp);
+ }
+ }
+ closedir(d);
+ out[n] = '\0';
+}
+
+static void
+rm_sessions(const char *home)
+{
+ char path[1100], f[2300];
+ DIR *d;
+ struct dirent *e;
+
+ snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+ if ((d = opendir(path)) != NULL) {
+ while ((e = readdir(d)) != NULL) {
+ if (e->d_name[0] == '.')
+ continue;
+ snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+ unlink(f);
+ }
+ closedir(d);
+ }
+ rmdir(path);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "compact_test: TIMED OUT\n");
+ _exit(97);
+}
+
+/* Read from fd into out (appending), stopping when needle appears or EOF. */
+static int
+read_until(int fd, char *out, size_t *len, size_t cap, const char *needle)
+{
+ ssize_t k;
+
+ while (*len < cap - 1) {
+ if (strstr(out, needle) != NULL)
+ return (1);
+ k = read(fd, out + *len, cap - 1 - *len);
+ if (k <= 0)
+ break;
+ *len += (size_t)k;
+ out[*len] = '\0';
+ }
+ return (strstr(out, needle) != NULL);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct sockaddr_in sa;
+ socklen_t slen;
+ pid_t sp, fp;
+ const char *fugu_bin;
+ char home[] = "/tmp/fugu-compact.XXXXXX";
+ char out[16384], jrnl[16384], portstr[16];
+ size_t outlen = 0;
+ int lfd, one = 1, port, status;
+ int in_pipe[2], out_pipe[2];
+
+ if (argc != 3)
+ errx(1, "usage: %s cert.pem key.pem", argv[0]);
+ if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+ errx(1, "FUGU_BIN not set");
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(90);
+
+ if (mkdtemp(home) == NULL)
+ err(1, "mkdtemp");
+ write_config(home);
+
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 2) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if ((sp = fork()) == -1)
+ err(1, "fork");
+ if (sp == 0) {
+ stub_server(lfd, argv[1], argv[2]);
+ _exit(0);
+ }
+ close(lfd);
+
+ if (pipe(in_pipe) == -1 || pipe(out_pipe) == -1)
+ err(1, "pipe");
+
+ if ((fp = fork()) == -1)
+ err(1, "fork");
+ if (fp == 0) {
+ dup2(in_pipe[0], STDIN_FILENO);
+ dup2(out_pipe[1], STDOUT_FILENO);
+ close(in_pipe[0]);
+ close(in_pipe[1]);
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+ setenv("HOME", home, 1);
+ { /* not setgid in the harness, so FUGU_CONF is honoured */
+ char confpath[1100];
+
+ snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+ home);
+ setenv("FUGU_CONF", confpath, 1);
+ }
+ setenv("FUGU_NET_HOST", "localhost", 1);
+ setenv("FUGU_NET_PORT", portstr, 1);
+ setenv("FUGU_NET_CAFILE", argv[1], 1);
+ execl(fugu_bin, fugu_bin, (char *)NULL);
+ _exit(127);
+ }
+ close(in_pipe[0]);
+ close(out_pipe[1]);
+
+ out[0] = '\0';
+
+ /* A normal turn first, to give /compact something to summarise. */
+ if (write(in_pipe[1], "ping\n", 5) != 5)
+ CHECK(0, "write ping");
+ CHECK(read_until(out_pipe[0], out, &outlen, sizeof(out), "pong"),
+ "normal turn streamed 'pong'");
+
+ /* Now compact the conversation. */
+ if (write(in_pipe[1], "/compact\n", 9) != 9)
+ CHECK(0, "write /compact");
+ CHECK(read_until(out_pipe[0], out, &outlen, sizeof(out),
+ "Compacted summary."), "compact summary shown to the user");
+ CHECK(strstr(out, "compacted") != NULL, "compact divider shown");
+
+ close(in_pipe[1]); /* EOF -> ui quits -> fugu shuts down */
+ while (read(out_pipe[0], out + outlen,
+ outlen < sizeof(out) - 1 ? sizeof(out) - 1 - outlen : 0) > 0)
+ ; /* drain */
+ close(out_pipe[0]);
+
+ if (waitpid(fp, &status, 0) == -1)
+ err(1, "waitpid fugu");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "fugu exit status %d", status);
+ if (waitpid(sp, &status, 0) == -1)
+ err(1, "waitpid stub");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "stub server exit %d", status);
+
+ /* The journal must record the compact marker carrying the summary. */
+ read_journal(home, jrnl, sizeof(jrnl));
+ CHECK(strstr(jrnl, "\"fugu\":\"compact\"") != NULL,
+ "journal has a compact marker");
+ CHECK(strstr(jrnl, "Compacted summary.") != NULL,
+ "compact marker carries the summary text");
+
+ {
+ char p[1100];
+
+ snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+ unlink(p);
+ rm_sessions(home);
+ snprintf(p, sizeof(p), "%s/.fugu", home);
+ rmdir(p);
+ rmdir(home);
+ }
+
+ printf("compact_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 5bdf2ee1a8d700b74d7cd8be68c8d376bb75262a (mode 644)
--- /dev/null
+++ regress/conf/Makefile
+# Unit tests for config resolution, scoping, and StrictModes (src/common/conf.c).
+# The parse.y grammar gets its own regress once it lands.
+
+PROG= conf_test
+SRCS= conf_test.c conf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-conf_test
+
+run-conf_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 775771f25e42fe29fa4e4e7c5361baff92a4e7b8 (mode 644)
--- /dev/null
+++ regress/conf/conf_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/stat.h>
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+
+static int fails;
+static int checks;
+
+#define CHECK(cond, msg) do { \
+ checks++; \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", \
+ __func__, __LINE__, (msg)); \
+ fails++; \
+ } \
+} while (0)
+
+static void
+streq(const char *func, int line, const char *got, const char *want,
+ const char *msg)
+{
+ checks++;
+ if (got == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s:%d: %s (got %s want %s)\n",
+ func, line, msg, got ? got : "(null)", want);
+ fails++;
+ }
+}
+#define STREQ(got, want, msg) streq(__func__, __LINE__, (got), (want), (msg))
+
+/* Build a representative config:
+ * global: model sonnet, max_tokens 8192, allow_write no
+ * match user isaac: model opus, allow_write yes
+ * match group interns: model haiku, web_search no
+ * match user isaac (2nd): model loser <- must never win (first-match-wins)
+ */
+static void
+build(struct conf *c)
+{
+ struct conf_match *m;
+
+ conf_init(c);
+
+ conf_set_string(&c->global, CS_MODEL, &c->global.model, "sonnet");
+ c->global.max_tokens = 8192;
+ conf_set_flag(&c->global, CS_MAX_TOKENS);
+ c->global.allow_write = 0;
+ conf_set_flag(&c->global, CS_ALLOW_WRITE);
+
+ m = conf_match_new(c, MATCH_USER, "isaac");
+ conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "opus");
+ m->settings.allow_write = 1;
+ conf_set_flag(&m->settings, CS_ALLOW_WRITE);
+
+ m = conf_match_new(c, MATCH_GROUP, "interns");
+ conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "haiku");
+ m->settings.web_search = 0;
+ conf_set_flag(&m->settings, CS_WEB_SEARCH);
+
+ m = conf_match_new(c, MATCH_USER, "isaac");
+ conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "loser");
+}
+
+static void
+test_user_match(void)
+{
+ struct conf c;
+ struct conf_settings eff;
+
+ build(&c);
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "isaac", NULL, 0, &eff);
+
+ STREQ(eff.model, "opus", "user block overrides global, first wins");
+ CHECK((eff.set & CS_ALLOW_WRITE) && eff.allow_write == 1,
+ "user block sets allow_write");
+ CHECK((eff.set & CS_MAX_TOKENS) && eff.max_tokens == 8192,
+ "global max_tokens inherited");
+ CHECK(!(eff.set & CS_WEB_SEARCH), "web_search left unset");
+
+ conf_settings_clear(&eff);
+ conf_clear(&c);
+}
+
+static void
+test_group_match(void)
+{
+ struct conf c;
+ struct conf_settings eff;
+ char *groups[] = { "wheel", "interns" };
+
+ build(&c);
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "bob", groups, 2, &eff);
+
+ STREQ(eff.model, "haiku", "group block matched");
+ CHECK((eff.set & CS_WEB_SEARCH) && eff.web_search == 0,
+ "group block sets web_search no");
+ CHECK((eff.set & CS_ALLOW_WRITE) && eff.allow_write == 0,
+ "allow_write falls back to global");
+
+ conf_settings_clear(&eff);
+ conf_clear(&c);
+}
+
+static void
+test_no_match(void)
+{
+ struct conf c;
+ struct conf_settings eff;
+
+ build(&c);
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "carol", NULL, 0, &eff);
+
+ STREQ(eff.model, "sonnet", "no block matches -> global default");
+ CHECK(eff.allow_write == 0, "global allow_write");
+
+ conf_settings_clear(&eff);
+ conf_clear(&c);
+}
+
+static void
+test_print_redacts(void)
+{
+ struct conf_settings s;
+ char *buf = NULL;
+ size_t sz = 0;
+ FILE *fp;
+
+ memset(&s, 0, sizeof(s));
+ conf_set_string(&s, CS_MODEL, &s.model, "opus");
+ conf_set_string(&s, CS_SYSTEM, &s.system, "be terse");
+ conf_set_string(&s, CS_ANTHROPIC_KEY, &s.anthropic_key, "sk-TOPSECRET");
+ conf_set_string(&s, CS_PROVIDER, &s.provider, "openai");
+ conf_set_string(&s, CS_API_HOST, &s.api_host, "openrouter.ai");
+ conf_set_string(&s, CS_API_PATH, &s.api_path, "/api/v1/chat/completions");
+ conf_set_string(&s, CS_API_KEY, &s.api_key, "sk-or-TOPSECRET");
+
+ fp = open_memstream(&buf, &sz);
+ if (fp == NULL) {
+ CHECK(0, "open_memstream");
+ return;
+ }
+ conf_settings_print(&s, fp);
+ fclose(fp);
+
+ CHECK(strstr(buf, "model \"opus\"") != NULL, "print shows model");
+ CHECK(strstr(buf, "system \"be terse\"") != NULL, "print shows system");
+ CHECK(strstr(buf, "provider \"openai\"") != NULL, "print shows provider");
+ CHECK(strstr(buf, "api_host \"openrouter.ai\"") != NULL,
+ "print shows api_host");
+ CHECK(strstr(buf, "api_path \"/api/v1/chat/completions\"") != NULL,
+ "print shows api_path");
+ CHECK(strstr(buf, "sk-TOPSECRET") == NULL, "secret value not printed");
+ CHECK(strstr(buf, "sk-or-TOPSECRET") == NULL, "api_key value not printed");
+ CHECK(strstr(buf, "***") != NULL, "secret redaction marker present");
+
+ free(buf);
+ conf_settings_clear(&s);
+}
+
+static void
+test_secrecy(void)
+{
+ char tmpl[] = "/tmp/fugu_secrecy.XXXXXX";
+ int fd;
+ uid_t me = getuid();
+
+ fd = mkstemp(tmpl);
+ CHECK(fd != -1, "mkstemp");
+ if (fd == -1)
+ return;
+
+ CHECK(fchmod(fd, 0600) == 0, "fchmod 0600");
+ CHECK(conf_check_secrecy(fd, tmpl, me) == 0, "0600 owned by us is ok");
+
+ /* Group read is allowed: that is how the setgid _fugu group reads it. */
+ CHECK(fchmod(fd, 0640) == 0, "fchmod 0640");
+ CHECK(conf_check_secrecy(fd, tmpl, me) == 0, "0640 (group read) is ok");
+
+ CHECK(fchmod(fd, 0644) == 0, "fchmod 0644");
+ CHECK(conf_check_secrecy(fd, tmpl, me) == -1, "0644 (world read) rejected");
+
+ CHECK(fchmod(fd, 0660) == 0, "fchmod 0660");
+ CHECK(conf_check_secrecy(fd, tmpl, me) == -1, "0660 (group write) rejected");
+
+ CHECK(fchmod(fd, 0600) == 0, "fchmod 0600 again");
+ CHECK(conf_check_secrecy(fd, tmpl, me + 1) == -1,
+ "owner neither root nor us rejected");
+
+ close(fd);
+ unlink(tmpl);
+}
+
+/*
+ * Local mirror of src/parent/parent.c:resolve_secret() (file-path branch only).
+ * resolve_secret() is static in parent.c and the helper cannot be linked here
+ * without pulling in the parent's worker/imsg/json plumbing. Keep this in
+ * sync with the production helper -- this test guards the empty-key-file
+ * regression where an empty first line was silently returned as a zero-length
+ * key, bypassing the caller's NULL check and disabling provider auth.
+ */
+static char *
+resolve_secret_file(const char *file_path)
+{
+ FILE *fp;
+ char *line = NULL;
+ size_t cap = 0;
+ ssize_t n;
+
+ if ((fp = fopen(file_path, "r")) == NULL)
+ return (NULL);
+ n = getline(&line, &cap, fp);
+ fclose(fp);
+ if (n <= 0) {
+ free(line);
+ return (NULL);
+ }
+ while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+ line[--n] = '\0';
+ if (n == 0) {
+ free(line);
+ return (NULL);
+ }
+ return (line);
+}
+
+/* Create a fresh temp key file with the given contents, return the path. */
+static char *
+make_keyfile(const char *content, size_t len)
+{
+ static const char PATTERN[] = "/tmp/fugu_keyfile.XXXXXX";
+ char *tmpl = strdup(PATTERN);
+ int fd;
+
+ if (tmpl == NULL)
+ return (NULL);
+ fd = mkstemp(tmpl);
+ if (fd == -1) {
+ free(tmpl);
+ return (NULL);
+ }
+ if (len > 0)
+ (void)write(fd, content, len);
+ close(fd);
+ return (tmpl);
+}
+
+static void
+test_resolve_secret_empty(void)
+{
+ char *path, *got;
+
+ /* Truly empty file: getline() returns -1, must yield NULL. */
+ path = make_keyfile("", 0);
+ CHECK(path != NULL, "mkstemp empty");
+ if (path != NULL) {
+ got = resolve_secret_file(path);
+ CHECK(got == NULL, "empty key file -> NULL (no bytes)");
+ free(got);
+ unlink(path);
+ free(path);
+ }
+
+ /* File containing only a newline: getline() returns 1, trim leaves
+ * length 0; the fix must convert this to NULL rather than returning
+ * a zero-length string. */
+ path = make_keyfile("\n", 1);
+ CHECK(path != NULL, "mkstemp newline");
+ if (path != NULL) {
+ got = resolve_secret_file(path);
+ CHECK(got == NULL, "key file with only newline -> NULL");
+ free(got);
+ unlink(path);
+ free(path);
+ }
+
+ /* CRLF-only file: same expectation. */
+ path = make_keyfile("\r\n", 2);
+ CHECK(path != NULL, "mkstemp CRLF");
+ if (path != NULL) {
+ got = resolve_secret_file(path);
+ CHECK(got == NULL, "key file with only CRLF -> NULL");
+ free(got);
+ unlink(path);
+ free(path);
+ }
+
+ /* Non-empty file: must still return the trimmed contents. */
+ path = make_keyfile("sk-abc\n", 7);
+ CHECK(path != NULL, "mkstemp non-empty");
+ if (path != NULL) {
+ got = resolve_secret_file(path);
+ STREQ(got, "sk-abc", "non-empty key file returns trimmed key");
+ free(got);
+ unlink(path);
+ free(path);
+ }
+}
+
+int
+main(void)
+{
+ test_user_match();
+ test_group_match();
+ test_no_match();
+ test_print_redacts();
+ test_secrecy();
+ test_resolve_secret_empty();
+
+ printf("%d checks, %d failures\n", checks, fails);
+ return (fails != 0);
+}
blob - /dev/null
blob + 71237af4e1757c39acc15151488483fd7e1a7664 (mode 644)
--- /dev/null
+++ regress/exec/Makefile
+# Integration test for the exec tool worker (exec_run.c, tools_file.c,
+# tools_shell.c).
+
+PROG= exec_test
+SRCS= exec_test.c exec_run.c tools_file.c tools_shell.c \
+ json.c buf.c util.c log.c imsgproto.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+EXECDIR= ${.CURDIR}/../../src/exec
+CFLAGS+= -I${COMMONDIR} -I${EXECDIR}
+.PATH: ${COMMONDIR} ${EXECDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-exec_test
+
+run-exec_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 38318ee15b719f0604141db433fb779e7624cf2e (mode 644)
--- /dev/null
+++ regress/exec/exec_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Drives the exec worker over imsg: a forked, sandboxed exec_loop in a temp
+ * project dir, exercised through M_TOOL_REQUEST/M_TOOL_RESULT. Verifies
+ * read/write/edit behaviour and file effects, the error cases (not found, not
+ * unique, missing file, unknown tool), the allow_write gate, and that a write
+ * outside the unveil'd cwd is refused.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int checks;
+static int failures;
+static char tmpdir[256];
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static void
+writefile(const char *name, const char *content)
+{
+ char p[512];
+ int fd;
+
+ snprintf(p, sizeof(p), "%s/%s", tmpdir, name);
+ if ((fd = open(p, O_CREAT | O_WRONLY | O_TRUNC, 0644)) == -1)
+ err(1, "open %s", p);
+ if (write(fd, content, strlen(content)) != (ssize_t)strlen(content))
+ err(1, "write %s", p);
+ close(fd);
+}
+
+static int
+readfile(const char *name, char *buf, size_t cap)
+{
+ char p[512];
+ ssize_t n;
+ int fd;
+
+ snprintf(p, sizeof(p), "%s/%s", tmpdir, name);
+ if ((fd = open(p, O_RDONLY)) == -1)
+ return (-1);
+ n = read(fd, buf, cap - 1);
+ close(fd);
+ if (n < 0)
+ return (-1);
+ buf[n] = '\0';
+ return ((int)n);
+}
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+ int r, n;
+
+ for (;;) {
+ if ((r = ip_get(ip, m)) == 1)
+ return (1);
+ if (r == -1)
+ return (-1);
+ if ((n = ip_read(ip)) <= 0)
+ return (0);
+ }
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+ const char *input_json)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_kv_string(&w, "name", name);
+ json_key(&w, "input");
+ json_raw(&w, input_json);
+ json_object_end(&w);
+ if (ip_compose(ip, M_TOOL_REQUEST, EP_EXEC, EP_NET, b.data,
+ b.len) == -1 || ip_flush(ip) == -1)
+ errx(1, "send_req");
+ buf_free(&b);
+}
+
+/* Receive one M_TOOL_RESULT; fills content and *is_error. Returns 0/-1. */
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+ struct fugu_msg m;
+ struct json_doc d;
+ char *c;
+ int t;
+
+ if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+ return (-1);
+ if (json_parse(&d, m.data, m.len) == -1) {
+ ip_msg_free(&m);
+ return (-1);
+ }
+ content[0] = '\0';
+ if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+ (c = json_tok_strdup(&d, t)) != NULL) {
+ strlcpy(content, c, cap);
+ free(c);
+ }
+ t = json_obj_get(&d, 0, "is_error");
+ *is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+ json_doc_free(&d);
+ ip_msg_free(&m);
+ return (0);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "exec_test: TIMED OUT\n");
+ _exit(97);
+}
+
+int
+main(void)
+{
+ struct imsgproto ip;
+ char content[4096], fb[4096], td[] = "/tmp/fugu-exec.XXXXXX";
+ const char *escape = "/tmp/fugu-exec-escape.txt";
+ pid_t pid;
+ int sv[2], status, is_err;
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(60);
+
+ if (mkdtemp(td) == NULL)
+ err(1, "mkdtemp");
+ strlcpy(tmpdir, td, sizeof(tmpdir));
+ unlink(escape);
+
+ writefile("hello.txt", "hello world");
+ writefile("dup.txt", "aXa"); /* 'a' twice -> not unique */
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+ if ((pid = fork()) == -1)
+ err(1, "fork");
+ if (pid == 0) {
+ struct imsgproto eip;
+
+ close(sv[0]);
+ if (chdir(tmpdir) == -1)
+ _exit(40);
+ if (ip_init(&eip, sv[1]) == -1)
+ _exit(41);
+ exec_sandbox(0); /* unveil "."=tmpdir, pledge */
+ exec_loop(&eip);
+ _exit(0);
+ }
+ close(sv[1]);
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ /* read */
+ send_req(&ip, "t1", "read", "{\"path\":\"hello.txt\"}");
+ CHECK(recv_result(&ip, content, sizeof(content), &is_err) == 0, "t1 recv");
+ CHECK(!is_err && strcmp(content, "hello world") == 0,
+ "read content '%s' err=%d", content, is_err);
+
+ /* write */
+ send_req(&ip, "t2", "write",
+ "{\"path\":\"new.txt\",\"content\":\"created data\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "write ok (%s)", content);
+ CHECK(readfile("new.txt", fb, sizeof(fb)) >= 0 &&
+ strcmp(fb, "created data") == 0, "write effect '%s'", fb);
+
+ /* edit (unique) */
+ send_req(&ip, "t3", "edit",
+ "{\"path\":\"hello.txt\",\"old_string\":\"world\","
+ "\"new_string\":\"fugu\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "edit ok (%s)", content);
+ CHECK(readfile("hello.txt", fb, sizeof(fb)) >= 0 &&
+ strcmp(fb, "hello fugu") == 0, "edit effect '%s'", fb);
+
+ /* edit: old_string not found */
+ send_req(&ip, "t4", "edit",
+ "{\"path\":\"hello.txt\",\"old_string\":\"zzz\","
+ "\"new_string\":\"x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "edit not-found is error");
+
+ /* edit: old_string not unique */
+ send_req(&ip, "t5", "edit",
+ "{\"path\":\"dup.txt\",\"old_string\":\"a\",\"new_string\":\"b\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "edit non-unique is error");
+
+ /* read missing file */
+ send_req(&ip, "t6", "read", "{\"path\":\"nope.txt\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "read missing is error");
+
+ /* write outside the unveil'd cwd is refused */
+ send_req(&ip, "t7", "write",
+ "{\"path\":\"/tmp/fugu-exec-escape.txt\",\"content\":\"x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "escape write is error");
+ CHECK(access(escape, F_OK) == -1, "escape file not created");
+
+ /* unknown tool */
+ send_req(&ip, "t8", "frobnicate", "{}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "unknown tool is error");
+
+ /* shell: command output is captured */
+ send_req(&ip, "sh1", "shell", "{\"command\":\"echo hello-shell\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "hello-shell") != NULL,
+ "shell echo '%s' err=%d", content, is_err);
+
+ /* shell: a non-zero exit status is reported, not an error */
+ send_req(&ip, "sh2", "shell", "{\"command\":\"exit 3\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "exit status 3") != NULL,
+ "shell exit code '%s' err=%d", content, is_err);
+
+ /* grep: find a pattern with file:line output */
+ writefile("src.txt", "alpha\nneedle here\nomega\n");
+ send_req(&ip, "g1", "grep",
+ "{\"pattern\":\"needle\",\"path\":\"src.txt\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "needle") != NULL,
+ "grep match '%s' err=%d", content, is_err);
+
+ /* grep: no match exits 1 but is not a tool error */
+ send_req(&ip, "g2", "grep",
+ "{\"pattern\":\"zzznomatch\",\"path\":\"src.txt\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "grep no-match not an error (%s)", content);
+
+ /* ls: lists the project directory */
+ send_req(&ip, "l1", "ls", "{\"path\":\".\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "src.txt") != NULL,
+ "ls lists src.txt '%s' err=%d", content, is_err);
+
+ /* find: locate a file by -name glob */
+ send_req(&ip, "f1", "find", "{\"path\":\".\",\"name\":\"src.txt\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "src.txt") != NULL,
+ "find by name '%s' err=%d", content, is_err);
+
+ /* allow_write gate */
+ {
+ struct buf cfg;
+
+ buf_init(&cfg);
+ buf_puts_cstr(&cfg, "{\"allow_write\":false}");
+ if (ip_compose(&ip, M_CONFIG, EP_EXEC, EP_PARENT, cfg.data,
+ cfg.len) == -1 || ip_flush(&ip) == -1)
+ errx(1, "config");
+ buf_free(&cfg);
+ }
+ send_req(&ip, "t9", "write",
+ "{\"path\":\"blocked.txt\",\"content\":\"nope\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "write blocked when allow_write=false");
+ CHECK(readfile("blocked.txt", fb, sizeof(fb)) == -1,
+ "blocked file not created");
+
+ if (ip_compose(&ip, M_SHUTDOWN, EP_EXEC, EP_PARENT, NULL, 0) != -1)
+ ip_flush(&ip);
+
+ if (waitpid(pid, &status, 0) == -1)
+ err(1, "waitpid");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "exec worker exit %d", status);
+
+ /* cleanup */
+ unlink(escape);
+ {
+ const char *f[] = {
+ "hello.txt", "dup.txt", "new.txt", "src.txt"
+ };
+ char p[512];
+ size_t i;
+
+ for (i = 0; i < sizeof(f) / sizeof(f[0]); i++) {
+ snprintf(p, sizeof(p), "%s/%s", tmpdir, f[i]);
+ unlink(p);
+ }
+ rmdir(tmpdir);
+ }
+
+ printf("exec_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + fae966d10a6b766a5029120db5cd9189ea0b4215 (mode 644)
--- /dev/null
+++ regress/fetch/Makefile
+# Integration test for the fetch web worker (fetch_run.c, kagi.c,
+# html2text.c) against a local libtls stub server. The url and httpacl
+# helpers were inlined into fetch_run.c and are exercised through it; the
+# tlsconn helper lives in src/common/tlsconn.c.
+
+PROG= fetch_test
+SRCS= fetch_test.c fetch_run.c kagi.c html2text.c http.c \
+ json.c buf.c util.c log.c imsgproto.c tlsconn.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+FETCHDIR= ${.CURDIR}/../../src/fetch
+CFLAGS+= -I${COMMONDIR} -I${FETCHDIR}
+.PATH: ${COMMONDIR} ${FETCHDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS= run-fetch_test
+
+run-fetch_test: ${PROG}
+ @td=`mktemp -d /tmp/fugu-fetch-cert.XXXXXX`; \
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout $$td/key.pem -out $$td/cert.pem \
+ -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+ ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+ rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2e8fe2cad202c565c37a9709f79a256dffe9fce6 (mode 644)
--- /dev/null
+++ regress/fetch/fetch_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Drives the fetch worker over imsg with a real libtls stub server: a forked,
+ * sandboxed fetch_loop is pointed (via M_CONFIG) at a local TLS server that
+ * answers the Kagi search path with canned JSON -- but only when the request
+ * carries the expected "Authorization: Bearer <token>" header, otherwise 401 -- and
+ * any other path with an HTML page. Verifies web_search formatting and that the
+ * key is sent (a dropped header fails the search), that the missing-token path
+ * errors without a connection, web_fetch HTML->text, bad-URL rejection, and that
+ * http_request enforces the operator's host allow/block lists.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "fetch_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* A Kagi Search API v1 success envelope: results under data.search[]. */
+static const char KAGI_JSON[] =
+ "{\"meta\":{\"node\":\"test\"},\"data\":{"
+ "\"related_search\":[{\"url\":\"/search?q=x\",\"title\":\"related-thing\"}],"
+ "\"search\":["
+ "{\"url\":\"https://example.com/a\",\"title\":\"Example A\","
+ "\"snippet\":\"first snippet\"},"
+ "{\"url\":\"https://example.com/b\",\"title\":\"Example B\","
+ "\"snippet\":\"second snippet\"}],"
+ "\"video\":[{\"url\":\"https://v/1\",\"title\":\"a video\"}]}}";
+
+static const char HTML_PAGE[] =
+ "<html><head><title>T</title><style>.x{color:red}</style></head>"
+ "<body><h1>Heading</h1><p>Hello & welcome to <b>fugu</b>.</p>"
+ "<script>alert('xss')</script></body></html>";
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tls_write(t, p + off, len - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n < 0)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+ struct tls_config *cfg;
+ struct tls *srv;
+
+ if ((cfg = tls_config_new()) == NULL)
+ _exit(21);
+ if (tls_config_set_cert_file(cfg, cert) == -1 ||
+ tls_config_set_key_file(cfg, key) == -1)
+ _exit(22);
+ if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+ _exit(23);
+
+ for (;;) {
+ struct tls *ctx;
+ struct buf resp;
+ char req[4096];
+ const char *body, *ctype, *status = "200 OK";
+ size_t off = 0;
+ ssize_t n;
+ int cfd;
+
+ if ((cfd = accept(lfd, NULL, NULL)) == -1)
+ break;
+ if (tls_accept_socket(srv, &ctx, cfd) == -1) {
+ close(cfd);
+ continue;
+ }
+ do {
+ n = tls_handshake(ctx);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n == -1) {
+ tls_free(ctx);
+ close(cfd);
+ continue;
+ }
+ /* Read the headers, then the POST body (so we can see the query). */
+ while (off < sizeof(req) - 1) {
+ n = tls_read(ctx, req + off, sizeof(req) - 1 - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n <= 0)
+ break;
+ off += (size_t)n;
+ req[off] = '\0';
+ if (strstr(req, "\r\n\r\n") != NULL)
+ break;
+ }
+ {
+ char *cl = strstr(req, "Content-Length:");
+ char *end = strstr(req, "\r\n\r\n");
+ size_t need, have;
+
+ if (cl != NULL && end != NULL) {
+ need = (size_t)strtol(cl + 15, NULL, 10);
+ have = off - (size_t)(end + 4 - req);
+ while (have < need && off < sizeof(req) - 1) {
+ n = tls_read(ctx, req + off,
+ sizeof(req) - 1 - off);
+ if (n == TLS_WANT_POLLIN ||
+ n == TLS_WANT_POLLOUT)
+ continue;
+ if (n <= 0)
+ break;
+ off += (size_t)n;
+ have += (size_t)n;
+ }
+ }
+ }
+ req[off] = '\0';
+
+ /*
+ * The /echo endpoint reports back the received body length and a
+ * hex dump of its first bytes, so the test can prove a body with
+ * an embedded NUL byte (e.g. "ab\0cd") survived end-to-end
+ * rather than being truncated at the first NUL by a strlen-based
+ * writer. Used only by the embedded-NUL regress test.
+ */
+ if (strstr(req, "POST /echo") != NULL) {
+ char *end = strstr(req, "\r\n\r\n");
+ size_t blen, i;
+ static char echobody[256];
+
+ blen = (end != NULL) ?
+ off - (size_t)(end + 4 - req) : 0;
+ snprintf(echobody, sizeof(echobody),
+ "body_len=%zu hex=", blen);
+ for (i = 0; i < blen && i < 16; i++) {
+ char hex[4];
+ snprintf(hex, sizeof(hex), "%02x ",
+ (unsigned char)(end + 4)[i]);
+ strlcat(echobody, hex, sizeof(echobody));
+ }
+ body = echobody;
+ ctype = "text/plain";
+ } else if (strstr(req, "POST /api/v1/search") != NULL) {
+ ctype = "application/json";
+ /*
+ * Authenticate like the real Kagi API: require the
+ * "Authorization: Bearer <token>" header carrying the token
+ * the parent delivered. Without it the search fails, so
+ * the success path asserts the key is sent correctly.
+ */
+ if (strstr(req, "Bearer test-token") == NULL) {
+ status = "401 Unauthorized";
+ body = "{\"errors\":[{\"code\":\"unauthorized\","
+ "\"message\":\"missing or bad token\"}]}";
+ } else if (strstr(req, "boom") != NULL) {
+ /* A non-200 with a v1 error envelope. */
+ status = "402 Payment Required";
+ body = "{\"errors\":[{\"code\":\"out_of_funds\","
+ "\"message\":\"Out of credits\"}]}";
+ } else {
+ body = KAGI_JSON;
+ }
+ } else {
+ body = HTML_PAGE;
+ ctype = "text/html; charset=utf-8";
+ }
+ buf_init(&resp);
+ buf_printf(&resp, "HTTP/1.1 %s\r\nContent-Type: %s\r\n"
+ "Content-Length: %zu\r\nConnection: close\r\n\r\n",
+ status, ctype, strlen(body));
+ buf_puts_cstr(&resp, body);
+ (void)tls_write_all(ctx, resp.data, resp.len);
+ buf_free(&resp);
+ tls_close(ctx);
+ tls_free(ctx);
+ close(cfd);
+ }
+ tls_free(srv);
+ tls_config_free(cfg);
+ _exit(0);
+}
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+ int r;
+
+ for (;;) {
+ if ((r = ip_get(ip, m)) == 1)
+ return (1);
+ if (r == -1)
+ return (-1);
+ if (ip_read(ip) <= 0)
+ return (0);
+ }
+}
+
+static void
+send_msg(struct imsgproto *ip, uint32_t type, const char *json)
+{
+ if (ip_compose(ip, type, EP_FETCH, EP_NET, json,
+ json != NULL ? strlen(json) : 0) == -1 || ip_flush(ip) == -1)
+ errx(1, "send_msg");
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+ const char *input_json)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_kv_string(&w, "name", name);
+ json_key(&w, "input");
+ json_raw(&w, input_json);
+ json_object_end(&w);
+ if (ip_compose(ip, M_TOOL_REQUEST, EP_FETCH, EP_NET, b.data,
+ b.len) == -1 || ip_flush(ip) == -1)
+ errx(1, "send_req");
+ buf_free(&b);
+}
+
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+ struct fugu_msg m;
+ struct json_doc d;
+ char *c;
+ int t;
+
+ if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+ return (-1);
+ if (json_parse(&d, m.data, m.len) == -1) {
+ ip_msg_free(&m);
+ return (-1);
+ }
+ content[0] = '\0';
+ if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+ (c = json_tok_strdup(&d, t)) != NULL) {
+ strlcpy(content, c, cap);
+ free(c);
+ }
+ t = json_obj_get(&d, 0, "is_error");
+ *is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+ json_doc_free(&d);
+ ip_msg_free(&m);
+ return (0);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "fetch_test: TIMED OUT\n");
+ _exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgproto ip;
+ struct sockaddr_in sa;
+ struct fugu_msg m;
+ socklen_t slen;
+ char content[8192], cfg[1024], url[256];
+ pid_t sp, fp;
+ int lfd, one = 1, port, sv[2], status, is_err;
+
+ if (argc != 3)
+ errx(1, "usage: %s cert.pem key.pem", argv[0]);
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(90);
+
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 4) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+
+ if ((sp = fork()) == -1)
+ err(1, "fork stub");
+ if (sp == 0) {
+ stub_server(lfd, argv[1], argv[2]);
+ _exit(0);
+ }
+ close(lfd);
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+ if ((fp = fork()) == -1)
+ err(1, "fork fetch");
+ if (fp == 0) {
+ struct imsgproto fip;
+
+ close(sv[0]);
+ if (ip_init(&fip, sv[1]) == -1)
+ _exit(41);
+ fetch_loop(&fip);
+ _exit(0);
+ }
+ close(sv[1]);
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ /* fetch announces itself, then we point it at the stub. */
+ CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "expect M_READY");
+ ip_msg_free(&m);
+
+ /* allow_private: the stub is on loopback, which the SSRF guard blocks. */
+ snprintf(cfg, sizeof(cfg), "{\"kagi_host\":\"localhost\","
+ "\"kagi_port\":\"%d\",\"cafile\":\"%s\",\"allow_private\":true}",
+ port, argv[1]);
+ send_msg(&ip, M_CONFIG, cfg);
+
+ /* web_search before the token arrives: error, and no connection made. */
+ send_req(&ip, "s0", "web_search", "{\"query\":\"anything\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "token") != NULL,
+ "web_search w/o token errors: '%s'", content);
+
+ send_msg(&ip, M_SECRET, "test-token");
+
+ /*
+ * web_search with the token: the stub returns results only when the
+ * request carried "Authorization: Bearer test-token", so a successful,
+ * formatted result here also proves the Kagi key is sent correctly.
+ */
+ send_req(&ip, "s1", "web_search", "{\"query\":\"fugu shell\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "web_search ok / Bearer auth header accepted (%s)", content);
+ CHECK(strstr(content, "Example A") != NULL &&
+ strstr(content, "https://example.com/a") != NULL &&
+ strstr(content, "first snippet") != NULL,
+ "web_search result A present: '%s'", content);
+ CHECK(strstr(content, "Example B") != NULL,
+ "web_search result B present");
+ CHECK(strstr(content, "related-thing") == NULL,
+ "web_search drops related (t=1) entries");
+
+ /* A non-200 Kagi reply surfaces its error message, not just the code. */
+ send_req(&ip, "s2", "web_search", "{\"query\":\"boom\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "402") != NULL &&
+ strstr(content, "Out of credits") != NULL,
+ "web_search surfaces Kagi's error message: '%s'", content);
+
+ /* web_fetch: HTML reduced to text, scripts/styles dropped. */
+ snprintf(url, sizeof(url), "https://localhost:%d/page", port);
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"%s\"}", url);
+ buf_putc(&in, '\0');
+ send_req(&ip, "f1", "web_fetch", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "web_fetch ok (%s)", content);
+ CHECK(strstr(content, "Heading") != NULL &&
+ strstr(content, "Hello & welcome to fugu") != NULL,
+ "web_fetch text content: '%s'", content);
+ CHECK(strstr(content, "alert") == NULL &&
+ strstr(content, "color") == NULL,
+ "web_fetch drops script/style: '%s'", content);
+
+ /* web_fetch with an unsupported scheme: rejected before any connect. */
+ send_req(&ip, "f2", "web_fetch", "{\"url\":\"ftp://localhost/x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "web_fetch bad URL is error");
+
+ /* http_request: enforce the operator's host allow/block lists. */
+ send_msg(&ip, M_CONFIG,
+ "{\"http_allow\":\"localhost\",\"http_block\":\"localhost/secret\"}");
+
+ /* Allowed host: reaches the stub; raw body returned with a status line. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/page\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h1", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP 200") != NULL,
+ "http_request allowed host ok: '%s'", content);
+ CHECK(strstr(content, "Heading") != NULL && strstr(content, "alert") != NULL,
+ "http_request returns the RAW body (no html2text): '%s'", content);
+
+ /* Host not in the allowlist: refused by the gate, before any connection. */
+ send_req(&ip, "h2", "http_request", "{\"url\":\"https://evil.example/x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "not allowed") != NULL,
+ "http_request denies a non-allowlisted host: '%s'", content);
+
+ /* Blocked path on an allowed host: the blocklist wins. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/secret/data\"}",
+ port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h3", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "not allowed") != NULL,
+ "http_request blocklist denies a blocked path: '%s'", content);
+
+ /* Non-https URL: rejected. */
+ send_req(&ip, "h4", "http_request", "{\"url\":\"http://localhost/x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "http_request rejects a non-https URL");
+
+ /*
+ * Embedded-NUL body: the JSON body decodes to a real NUL byte.
+ * Before the fix, do_http_request derived Content-Length from
+ * strlen(reqbody) and truncated the body at the first NUL (so the
+ * stub would see body_len=2, hex=68 69). After the fix it must
+ * survive: body_len=5, hex=68 69 00 21 21. The stub's /echo path
+ * responds with a plain-text trace of what it actually received.
+ */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/echo\","
+ "\"method\":\"POST\","
+ "\"body\":\"hi\\u0000!!\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "n0", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err, "http_request with NUL-bearing body succeeds: '%s'",
+ content);
+ CHECK(strstr(content, "body_len=5") != NULL,
+ "http_request body NOT truncated at NUL: '%s'", content);
+ CHECK(strstr(content, "68 69 00 21 21") != NULL,
+ "http_request body bytes intact incl. NUL: '%s'", content);
+
+ /* URL with an embedded NUL: rejected before any connect. */
+ send_req(&ip, "n1", "http_request",
+ "{\"url\":\"https://localhost\\u0000evil/x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "NUL") != NULL,
+ "http_request rejects URL with embedded NUL: '%s'", content);
+
+ /* Header value with an embedded NUL: rejected, no request sent. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/echo\","
+ "\"method\":\"POST\","
+ "\"headers\":{\"x-trace\":\"a\\u0000b\"},"
+ "\"body\":\"x\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "n2", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "NUL") != NULL,
+ "http_request rejects header value with NUL: '%s'", content);
+
+ /* web_search query with an embedded NUL: rejected, no Kagi call. */
+ send_req(&ip, "n3", "web_search",
+ "{\"query\":\"foo\\u0000bar\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "NUL") != NULL,
+ "web_search rejects query with NUL: '%s'", content);
+
+ /*
+ * Path-prefix ACL boundary: an allowlist of "localhost/v1" must not let
+ * a sibling path like "/v1admin/secrets" slip through on a raw prefix
+ * match -- the prefix has to end at a path-segment boundary ('/' or end
+ * of path). Legitimate paths ("/v1", "/v1/", "/v1/whatever") are still
+ * allowed.
+ */
+ send_msg(&ip, M_CONFIG,
+ "{\"http_allow\":\"localhost/v1\",\"http_block\":\"\"}");
+
+ /* BAD path: must be REJECTED (regression for the boundary bypass). */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://localhost:%d/v1admin/secrets\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h5", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "not allowed") != NULL,
+ "http_request path-prefix boundary: /v1admin/secrets is rejected "
+ "by an allowlist of /v1: '%s'", content);
+
+ /* GOOD: bare "/v1" still allowed. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h6", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request path-prefix allows /v1 itself: '%s'", content);
+
+ /* GOOD: "/v1/" (trailing slash) still allowed. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/v1/\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h7", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request path-prefix allows /v1/ itself: '%s'", content);
+
+ /* GOOD: "/v1/whatever" (deeper path under the prefix) still allowed. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://localhost:%d/v1/whatever\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h8", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request path-prefix allows /v1/whatever: '%s'", content);
+
+ /*
+ * GOOD: "/v1?token=abc" -- a query string sits at the prefix boundary,
+ * just like '/'. Before the boundary set was extended, '?' was treated
+ * as a sibling-path byte and this URL was wrongly denied.
+ */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://localhost:%d/v1?token=abc\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h9", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request path-prefix allows /v1?token=abc: '%s'", content);
+
+ /*
+ * GOOD: "/v1#frag" -- a fragment also marks the prefix boundary.
+ * In practice url_parse() already drops '#...' before the ACL sees
+ * the path, so this case asserts the end-to-end behaviour rather than
+ * exercising the boundary byte directly.
+ */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://localhost:%d/v1#frag\"}", port);
+ buf_putc(&in, '\0');
+ send_req(&ip, "h10", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request path-prefix allows /v1#frag: '%s'", content);
+
+ /*
+ * Host-match ACL boundary: with an EXACT pattern "localhost", a host
+ * like "evillocalhost" must not match by string-contains; the rule is
+ * full-equality on non-dot patterns, suffix match only when the
+ * pattern starts with '.'. These check both halves of the rule -- a
+ * regression would let evilhosts.example.com slip past an allowlist of
+ * "example.com". Detected as the ACL "not allowed" rejection (which
+ * fires before dial), so the lookup of the bad host never happens.
+ */
+ send_msg(&ip, M_CONFIG,
+ "{\"http_allow\":\"localhost\",\"http_block\":\"\"}");
+
+ /* BAD host: "evillocalhost" must NOT match the exact pattern "localhost". */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://evillocalhost:%d/v1\"}", port);
+ buf_terminate(&in);
+ send_req(&ip, "h11", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "not allowed") != NULL,
+ "http_request host-exact rejects evillocalhost vs localhost: '%s'",
+ content);
+
+ /* GOOD host: "localhost" matches itself exactly. */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+ buf_terminate(&in);
+ send_req(&ip, "h12", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request host-exact allows localhost: '%s'", content);
+
+ /*
+ * Dot-suffix rule: ".localhost" matches the bare "localhost" AND any
+ * "*.localhost", but NOT "evillocalhost" -- the leading '.' anchors
+ * the suffix at a label boundary, so a pattern like ".example.com"
+ * cannot be slipped by "evilexample.com". We exercise both the
+ * bare-domain branch (host equals pattern minus the dot) and the
+ * boundary rejection. The other branch (real "*.localhost") would
+ * need a multi-SAN cert; the boundary case below is what catches the
+ * historical bypass shape.
+ */
+ send_msg(&ip, M_CONFIG,
+ "{\"http_allow\":\".localhost\",\"http_block\":\"\"}");
+
+ /* GOOD host: bare "localhost" matches pattern ".localhost". */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+ buf_terminate(&in);
+ send_req(&ip, "h13", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "HTTP") != NULL,
+ "http_request dot-suffix allows bare localhost vs .localhost: '%s'",
+ content);
+
+ /* BAD host: "evillocalhost" must NOT match dot-suffix ".localhost". */
+ {
+ struct buf in;
+
+ buf_init(&in);
+ buf_printf(&in,
+ "{\"url\":\"https://evillocalhost:%d/v1\"}", port);
+ buf_terminate(&in);
+ send_req(&ip, "h14", "http_request", in.data);
+ buf_free(&in);
+ }
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err && strstr(content, "not allowed") != NULL,
+ "http_request dot-suffix rejects evillocalhost vs .localhost "
+ "(label-boundary anchor): '%s'", content);
+
+ send_msg(&ip, M_SHUTDOWN, NULL);
+ if (waitpid(fp, &status, 0) == -1)
+ err(1, "waitpid fetch");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "fetch worker exit %d", status);
+
+ kill(sp, SIGTERM);
+ waitpid(sp, &status, 0);
+
+ printf("fetch_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 8dd94e5c75ff95ca3a535ca4cf4d8b4422654569 (mode 644)
--- /dev/null
+++ regress/fuzzy/Makefile
+# Unit tests for the fuzzy matcher (src/common/fuzzy.c).
+
+PROG= fuzzy_test
+SRCS= fuzzy_test.c fuzzy.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+REGRESS_TARGETS= run-fuzzy_test
+
+run-fuzzy_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6af9917ae56270df7a40241160b02ff68e1a8df7 (mode 644)
--- /dev/null
+++ regress/fuzzy/fuzzy_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+
+#include "fuzzy.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static void
+test_basic(void)
+{
+ int s;
+
+ CHECK(fuzzy_match("", "anything", &s) == 1 && s == 0,
+ "empty needle matches everything, score 0");
+ CHECK(fuzzy_match("son", "claude-sonnet-4-6", &s) == 1,
+ "subsequence matches");
+ CHECK(fuzzy_match("gpt4", "gpt-4o-mini", &s) == 1,
+ "scattered subsequence matches (g p t 4)");
+ CHECK(fuzzy_match("xyz", "claude-sonnet", &s) == 0 && s == 0,
+ "no subsequence -> no match, score 0");
+ CHECK(fuzzy_match("sonnetx", "claude-sonnet", &s) == 0,
+ "needle longer than available tail -> no match");
+ CHECK(fuzzy_match("SON", "claude-sonnet", &s) == 1,
+ "case-insensitive (upper needle)");
+ CHECK(fuzzy_match("son", "CLAUDE-SONNET", &s) == 1,
+ "case-insensitive (upper haystack)");
+}
+
+static void
+test_scoring(void)
+{
+ int a, b;
+
+ /* A boundary-aligned hit outscores a mid-word one. */
+ CHECK(fuzzy_match("son", "claude-sonnet", &a) == 1 &&
+ fuzzy_match("son", "personals", &b) == 1 && a > b,
+ "boundary-aligned 'son' (a=%d) beats mid-word (b=%d)", a, b);
+
+ /* A consecutive run outscores the same chars scattered mid-word. */
+ CHECK(fuzzy_match("gpt", "gpt-4o", &a) == 1 &&
+ fuzzy_match("gpt", "gxpxtx", &b) == 1 && a > b,
+ "consecutive 'gpt' (a=%d) beats scattered mid-word (b=%d)", a, b);
+
+ /* Matching each segment start (acronym-like) also scores well. */
+ CHECK(fuzzy_match("gpt", "g-p-t-x", &a) == 1 && a > 12,
+ "boundary-aligned acronym 'gpt' scores high (a=%d)", a);
+
+ /* A full prefix is a strong score (boundary + run). */
+ CHECK(fuzzy_match("claude", "claude-sonnet-4-6", &a) == 1 && a > 20,
+ "prefix 'claude' scores high (a=%d)", a);
+
+ /* NULL score pointer is tolerated. */
+ CHECK(fuzzy_match("son", "claude-sonnet", NULL) == 1,
+ "NULL score pointer ok");
+}
+
+int
+main(void)
+{
+ test_basic();
+ test_scoring();
+ printf("fuzzy_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 740d8b5359f0a873e9531869ba1f8096ee7fa7fc (mode 644)
--- /dev/null
+++ regress/html2text/Makefile
+# Unit test for the HTML-to-text reducer (src/common/html2text.c).
+
+PROG= html2text_test
+SRCS= html2text_test.c html2text.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-html2text_test
+
+run-html2text_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6a0f6a9bcc0791688503c5e0ffa08803d42b4c84 (mode 644)
--- /dev/null
+++ regress/html2text/html2text_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "html2text.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* Run html2text and compare the (NUL-terminated) result exactly. */
+static void
+eq(const char *html, const char *want)
+{
+ struct buf b;
+
+ buf_init(&b);
+ html2text(html, strlen(html), &b);
+ buf_putc(&b, '\0');
+ CHECK(strcmp(b.data, want) == 0, "in=<%s> got=<%s> want=<%s>",
+ html, b.data, want);
+ buf_free(&b);
+}
+
+/* Run html2text and check a substring is present / absent. */
+static void
+has(const char *html, const char *needle, int present)
+{
+ struct buf b;
+ int found;
+
+ buf_init(&b);
+ html2text(html, strlen(html), &b);
+ buf_putc(&b, '\0');
+ found = (strstr(b.data, needle) != NULL);
+ CHECK(found == present, "in=<%s> expected %s'%s' in <%s>", html,
+ present ? "" : "no ", needle, b.data);
+ buf_free(&b);
+}
+
+/*
+ * Validate that bytes[0..n) is a well-formed UTF-8 sequence (no truncated
+ * multi-byte codepoints, no stray continuation bytes, no overlongs).
+ */
+static int
+is_well_formed_utf8(const unsigned char *bytes, size_t n)
+{
+ size_t i = 0;
+
+ while (i < n) {
+ unsigned char c = bytes[i];
+ size_t need, k;
+ unsigned long cp;
+
+ if (c < 0x80) {
+ i++;
+ continue;
+ }
+ if ((c & 0xe0) == 0xc0) {
+ need = 2;
+ cp = c & 0x1f;
+ } else if ((c & 0xf0) == 0xe0) {
+ need = 3;
+ cp = c & 0x0f;
+ } else if ((c & 0xf8) == 0xf0) {
+ need = 4;
+ cp = c & 0x07;
+ } else
+ return (0); /* stray continuation or invalid lead */
+ if (i + need > n)
+ return (0); /* truncated sequence */
+ for (k = 1; k < need; k++) {
+ if ((bytes[i + k] & 0xc0) != 0x80)
+ return (0);
+ cp = (cp << 6) | (bytes[i + k] & 0x3f);
+ }
+ /* reject overlongs and surrogates / out-of-range */
+ if (need == 2 && cp < 0x80)
+ return (0);
+ if (need == 3 && cp < 0x800)
+ return (0);
+ if (need == 4 && cp < 0x10000)
+ return (0);
+ if (cp >= 0xd800 && cp <= 0xdfff)
+ return (0);
+ if (cp > 0x10ffff)
+ return (0);
+ i += need;
+ }
+ return (1);
+}
+
+/*
+ * Regress for the low-UTF-8 boundary bug: drive html2text right up to
+ * HTML2TEXT_MAXOUT and then emit a 3-byte UTF-8 codepoint. Before the fix the
+ * cap was checked one byte at a time, so the lead byte slipped through and the
+ * continuation bytes were dropped, leaving a malformed sequence at the tail.
+ * After the fix the whole codepoint is dropped and the output stays
+ * well-formed UTF-8 of length <= HTML2TEXT_MAXOUT.
+ */
+static void
+test_utf8_boundary(void)
+{
+ const char *emdash = "—"; /* U+2014, 3 bytes UTF-8 */
+ size_t emlen = strlen(emdash);
+ size_t fill, total, off;
+ char *html;
+ struct buf b;
+
+ /*
+ * Fill so the first emitted byte of the em dash lands at offset
+ * HTML2TEXT_MAXOUT - 1. With the old code byte 1 fits, bytes 2-3 are
+ * dropped, and the buffer ends in a malformed UTF-8 lead byte.
+ */
+ fill = HTML2TEXT_MAXOUT - 1;
+ total = fill + emlen + 1;
+ html = malloc(total);
+ CHECK(html != NULL, "malloc(%zu) failed", total);
+ if (html == NULL)
+ return;
+ memset(html, 'a', fill);
+ memcpy(html + fill, emdash, emlen);
+ html[total - 1] = '\0';
+
+ buf_init(&b);
+ html2text(html, fill + emlen, &b);
+ CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap %u", b.len,
+ HTML2TEXT_MAXOUT);
+ CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+ "output not well-formed UTF-8 (len=%zu, last byte=0x%02x)",
+ b.len, b.len > 0 ?
+ (unsigned char)b.data[b.len - 1] : 0);
+ /*
+ * Also try a 2-byte (U+00A9 copyright) and a 4-byte (U+1F600) codepoint
+ * at the same boundary to exercise every encoding width.
+ */
+ buf_free(&b);
+ buf_init(&b);
+ memcpy(html + fill, "©", 6);
+ html2text(html, fill + 6, &b);
+ CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap (2-byte)",
+ b.len);
+ CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+ "2-byte boundary: output not well-formed UTF-8");
+
+ buf_free(&b);
+ buf_init(&b);
+ memcpy(html + fill, "😀", 9);
+ html2text(html, fill + 9, &b);
+ CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap (4-byte)",
+ b.len);
+ CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+ "4-byte boundary: output not well-formed UTF-8");
+
+ /*
+ * And a sweep across a few offsets just before the cap so we hit the
+ * case where the first byte fits but the rest do not, regardless of
+ * exactly which offset the writes started from.
+ */
+ for (off = 2; off <= 5; off++) {
+ fill = HTML2TEXT_MAXOUT - off;
+ memset(html, 'a', fill);
+ memcpy(html + fill, emdash, emlen);
+ buf_free(&b);
+ buf_init(&b);
+ html2text(html, fill + emlen, &b);
+ CHECK(b.len <= HTML2TEXT_MAXOUT,
+ "sweep off=%zu: len %zu exceeds cap", off, b.len);
+ CHECK(is_well_formed_utf8((const unsigned char *)b.data,
+ b.len),
+ "sweep off=%zu: output not well-formed UTF-8", off);
+ }
+ buf_free(&b);
+ free(html);
+}
+
+/*
+ * Regress for the named-entity boundary bug: emit_named used to write its
+ * replacement one byte at a time, so a 3-byte expansion like "..." for …
+ * could be cut mid-sequence at HTML2TEXT_MAXOUT, leaving a stray "." or ".."
+ * that is logically half a replacement. After the fix the whole replacement
+ * is dropped when it doesn't fit, so the output never ends in a fragment.
+ */
+static void
+test_named_boundary(void)
+{
+ const char *hellip = "…"; /* expands to "..." (3 bytes) */
+ size_t hlen = strlen(hellip);
+ size_t fill, total;
+ char *html;
+ struct buf b;
+
+ /*
+ * Fill so the output is at the cap minus 2 bytes; the 3-byte "..."
+ * replacement does not fit. Before the fix the first two '.' bytes
+ * slipped through and the third was dropped, leaving a stray ".." at
+ * the tail. After the fix the whole "..." is dropped.
+ */
+ fill = HTML2TEXT_MAXOUT - 2;
+ total = fill + hlen + 1;
+ html = malloc(total);
+ CHECK(html != NULL, "malloc(%zu) failed", total);
+ if (html == NULL)
+ return;
+ memset(html, 'a', fill);
+ memcpy(html + fill, hellip, hlen);
+ html[total - 1] = '\0';
+
+ buf_init(&b);
+ html2text(html, fill + hlen, &b);
+ CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap %u", b.len,
+ HTML2TEXT_MAXOUT);
+ /*
+ * The replacement must be all-or-nothing: a trailing '.' would mean a
+ * fragment of "..." leaked through. The fill bytes are 'a', so any
+ * trailing '.' can only have come from the truncated replacement.
+ */
+ CHECK(b.len == 0 || b.data[b.len - 1] != '.',
+ "output ends in stray '.' (len=%zu)", b.len);
+ buf_free(&b);
+ free(html);
+}
+
+int
+main(void)
+{
+ eq("<p>Hello <b>world</b></p>", "Hello world");
+ eq("a&b", "a&b");
+ eq("xAy", "xAy");
+ eq("xAy", "xAy");
+ eq("a b", "a b");
+ eq(" spaced out ", "spaced out");
+ eq("<div>a</div><div>b</div>", "a\n\nb");
+ eq("&foo;", "&foo;"); /* unknown entity kept */
+ eq("plain text", "plain text");
+ eq("<a href=\"x\">link</a>", "link"); /* attribute scanning */
+ eq("a<br>b", "a\nb");
+
+ /* script / style / comment content is dropped */
+ has("<script>alert(1)</script>hi", "alert", 0);
+ has("<script>alert(1)</script>hi", "hi", 1);
+ has("<style>.x{color:red}</style>Y", "color", 0);
+ has("<style>.x{color:red}</style>Y", "Y", 1);
+ has("<!-- secret -->visible", "secret", 0);
+ has("<!-- secret -->visible", "visible", 1);
+
+ /* a tag with '>' inside a quoted attribute is one tag */
+ has("<img alt=\"a>b\">text", "text", 1);
+ has("<img alt=\"a>b\">text", "a>b", 0);
+
+ /* entity that decodes to a multibyte codepoint stays intact */
+ has("—", "\xe2\x80\x94", 1); /* em dash U+2014 */
+
+ /* a fake close tag (prefix without delimiter) must NOT end the skip */
+ has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "LEAK", 0);
+ has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "HIDDEN", 0);
+ has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "VISIBLE", 1);
+ /* a real close with trailing space/attrs still ends the skip */
+ eq("<script>s</script >after", "after");
+ eq("<style>a</style\n>b", "b");
+
+ /* surrogate and out-of-range numeric refs are dropped (valid UTF-8 only) */
+ eq("a�b", "ab");
+ eq("a�b", "ab");
+
+ /* a multi-byte codepoint right at HTML2TEXT_MAXOUT must not be split */
+ test_utf8_boundary();
+
+ /* a named-entity expansion must not be cut mid-sequence at the cap */
+ test_named_boundary();
+
+ printf("html2text_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 6340205bc9c51f176e89e2c52968657b04470a55 (mode 644)
--- /dev/null
+++ regress/http/Makefile
+# Unit tests for the HTTP/1.1 request builder + response parser (common/http.c).
+
+PROG= http_test
+SRCS= http_test.c http.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-http_test
+
+run-http_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + a2fda5a98f5fdffeeaf3985b7ef56c46a57957d6 (mode 644)
--- /dev/null
+++ regress/http/http_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static char body[1 << 16];
+static size_t bodylen;
+
+static void
+on_body(const void *d, size_t n, void *arg)
+{
+ (void)arg;
+ if (n == 0) {
+ failures++; /* the contract forbids empty calls */
+ return;
+ }
+ if (bodylen + n > sizeof(body)) {
+ failures++;
+ return;
+ }
+ memcpy(body + bodylen, d, n);
+ bodylen += n;
+}
+
+static void
+reset_body(void)
+{
+ bodylen = 0;
+}
+
+/* Push a whole response in fixed-size pieces to stress incremental parsing. */
+static int
+push_chunks(struct http_resp *r, const char *s, size_t step)
+{
+ size_t off, n, total = strlen(s);
+
+ for (off = 0; off < total; off += n) {
+ n = total - off;
+ if (n > step)
+ n = step;
+ if (http_resp_push(r, s + off, n) == -1)
+ return (-1);
+ }
+ return (0);
+}
+
+static void
+test_build_request(void)
+{
+ struct buf out;
+ struct http_header h[] = {
+ { "x-api-key", "secret" },
+ { "anthropic-version", "2023-06-01" },
+ { "content-type", "application/json" },
+ };
+ const char *jb = "{\"x\":1}";
+ const char *expect =
+ "POST /v1/messages HTTP/1.1\r\n"
+ "Host: api.anthropic.com\r\n"
+ "Connection: close\r\n"
+ "x-api-key: secret\r\n"
+ "anthropic-version: 2023-06-01\r\n"
+ "content-type: application/json\r\n"
+ "Content-Length: 7\r\n"
+ "\r\n"
+ "{\"x\":1}";
+
+ buf_init(&out);
+ http_build_request(&out, "POST", "api.anthropic.com", "/v1/messages",
+ h, 3, jb, strlen(jb));
+ buf_terminate(&out);
+ CHECK(strcmp(out.data, expect) == 0, "build request:\n%s", out.data);
+ buf_free(&out);
+}
+
+static void
+test_content_length(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: application/json\r\n"
+ "Content-Length: 13\r\n"
+ "\r\n"
+ "hello, world!";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "len: push");
+ CHECK(r.status == 200, "len: status=%d", r.status);
+ CHECK(http_resp_done(&r), "len: not done");
+ CHECK(bodylen == 13 && memcmp(body, "hello, world!", 13) == 0,
+ "len: body");
+ CHECK(r.ctype.len == 16 &&
+ memcmp(r.ctype.data, "application/json", 16) == 0, "len: ctype");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_chunked(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "\r\n"
+ "5\r\nhello\r\n"
+ "7\r\n, world\r\n"
+ "0\r\n\r\n";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "chunked: push");
+ CHECK(http_resp_done(&r), "chunked: not done");
+ CHECK(bodylen == 12 && memcmp(body, "hello, world", 12) == 0,
+ "chunked: body");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_chunked_bytewise(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "\r\n"
+ "5\r\nhello\r\n7\r\n, world\r\n0\r\n\r\n";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(push_chunks(&r, resp, 1) == 0, "bytewise: push");
+ CHECK(http_resp_done(&r), "bytewise: not done");
+ CHECK(bodylen == 12 && memcmp(body, "hello, world", 12) == 0,
+ "bytewise: body");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_chunk_extension(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "\r\n"
+ "5;foo=bar\r\nhello\r\n0\r\n\r\n";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "ext: push");
+ CHECK(http_resp_done(&r) && bodylen == 5 &&
+ memcmp(body, "hello", 5) == 0, "ext: body");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_status_codes(void)
+{
+ struct http_resp r;
+ const char *r404 =
+ "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
+ const char *r500 =
+ "HTTP/2 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, r404, strlen(r404)) == 0, "404: push");
+ CHECK(r.status == 404 && http_resp_done(&r), "404: status/done");
+ http_resp_clear(&r);
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, r500, strlen(r500)) == 0, "500: push");
+ CHECK(r.status == 500 && http_resp_done(&r), "500: status/done");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_eof_framed(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "streamed bytes";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "eof: push");
+ CHECK(!http_resp_done(&r), "eof: done too early");
+ CHECK(http_resp_eof(&r) == 0, "eof: close");
+ CHECK(http_resp_done(&r), "eof: not done after close");
+ CHECK(bodylen == 14 && memcmp(body, "streamed bytes", 14) == 0,
+ "eof: body");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_malformed_status(void)
+{
+ struct http_resp r;
+ const char *resp = "NOPE\r\n\r\n";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == -1, "malformed status");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_no_body_status(void)
+{
+ struct http_resp r;
+ const char *r204 =
+ "HTTP/1.1 204 No Content\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "SURPRISE"; /* a hostile server's smuggled bytes */
+ const char *r304 =
+ "HTTP/1.1 304 Not Modified\r\n\r\nXYZ";
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, r204, strlen(r204)) == 0, "204: push");
+ CHECK(r.status == 204 && http_resp_done(&r), "204: done immediately");
+ CHECK(bodylen == 0, "204: no body delivered (bodylen=%zu)", bodylen);
+ http_resp_clear(&r);
+ reset_body();
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, r304, strlen(r304)) == 0, "304: push");
+ CHECK(r.status == 304 && http_resp_done(&r) && bodylen == 0,
+ "304: done, no body");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_bad_chunk_terminator(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "\r\n"
+ "5\r\nhelloX\r\n0\r\n\r\n"; /* 'X' where the CRLF must be */
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == -1,
+ "bad chunk terminator rejected");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+static void
+test_truncated_chunk(void)
+{
+ struct http_resp r;
+ const char *resp =
+ "HTTP/1.1 200 OK\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "\r\n"
+ "5\r\nhel"; /* claims 5 bytes, delivers 3, then closes */
+
+ http_resp_init(&r, on_body, NULL);
+ CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "trunc: push");
+ CHECK(http_resp_eof(&r) == -1, "trunc: eof should error");
+ http_resp_clear(&r);
+ reset_body();
+}
+
+int
+main(void)
+{
+ test_build_request();
+ test_content_length();
+ test_chunked();
+ test_chunked_bytewise();
+ test_chunk_extension();
+ test_status_codes();
+ test_eof_framed();
+ test_malformed_status();
+ test_no_body_status();
+ test_bad_chunk_terminator();
+ test_truncated_chunk();
+
+ printf("http_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 1b1cb374e3903245244047b5afb8252208d2dbed (mode 644)
--- /dev/null
+++ regress/imsg/Makefile
+# Unit tests for the imsg chunked message layer (src/common/imsgproto.c).
+
+PROG= imsg_test
+SRCS= imsg_test.c imsgproto.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-imsg_test
+
+run-imsg_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2cf997ad5d164b10e68555279af35dff40fa3c6d (mode 644)
--- /dev/null
+++ regress/imsg/imsg_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Round-trip the imsgproto chunked layer through a forked echo peer over a
+ * socketpair: small (single-chunk) and large (multi-chunk) messages, the exact
+ * chunk-size boundary, and an empty message, all verified byte-for-byte.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "imsgproto.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static void
+fill(uint8_t *b, size_t n, uint32_t seed)
+{
+ size_t i;
+
+ for (i = 0; i < n; i++)
+ b[i] = (uint8_t)(seed + i * 31u + (i >> 8));
+}
+
+/* Block until one complete logical message arrives. 1 ok, 0 EOF, -1 err. */
+static int
+recv_one(struct imsgproto *ip, struct fugu_msg *m)
+{
+ int r, n;
+
+ for (;;) {
+ if ((r = ip_get(ip, m)) == 1)
+ return (1);
+ if (r == -1)
+ return (-1);
+ if ((n = ip_read(ip)) == 0)
+ return (0);
+ if (n == -1)
+ return (-1);
+ }
+}
+
+/* Echo peer: bounce every message back with src/dst swapped, until SHUTDOWN. */
+static void
+child_main(int fd)
+{
+ struct imsgproto ip;
+ struct fugu_msg m;
+
+ if (ip_init(&ip, fd) == -1)
+ _exit(2);
+
+ for (;;) {
+ if (recv_one(&ip, &m) != 1)
+ break;
+ if (m.type == M_SHUTDOWN) {
+ ip_msg_free(&m);
+ break;
+ }
+ if (ip_compose(&ip, m.type, m.src, m.dst, m.data, m.len) == -1)
+ _exit(3);
+ ip_msg_free(&m);
+ if (ip_flush(&ip) == -1)
+ _exit(4);
+ }
+ ip_clear(&ip);
+ _exit(0);
+}
+
+static void
+roundtrip(struct imsgproto *ip, size_t n, uint32_t seed)
+{
+ struct fugu_msg m;
+ uint8_t *out = NULL;
+ int r;
+
+ if (n > 0) {
+ out = malloc(n);
+ if (out == NULL)
+ err(1, "malloc");
+ fill(out, n, seed);
+ }
+
+ CHECK(ip_compose(ip, M_SUBMIT, EP_NET, EP_UI, out, n) == 0,
+ "compose n=%zu", n);
+ CHECK(ip_flush(ip) == 0, "flush n=%zu", n);
+
+ r = recv_one(ip, &m);
+ CHECK(r == 1, "recv n=%zu returned %d", n, r);
+ if (r == 1) {
+ CHECK(m.type == M_SUBMIT, "n=%zu type=%u", n, m.type);
+ CHECK(m.src == EP_NET, "n=%zu src=%u (echo should come from net)",
+ n, m.src);
+ CHECK(m.dst == EP_UI, "n=%zu dst=%u", n, m.dst);
+ CHECK(m.len == n, "n=%zu echoed len=%zu", n, m.len);
+ if (n == 0)
+ CHECK(m.data == NULL, "n=0 data should be NULL");
+ else {
+ CHECK(m.data != NULL, "n=%zu data NULL", n);
+ CHECK(m.data != NULL && memcmp(m.data, out, n) == 0,
+ "n=%zu payload mismatch", n);
+ }
+ ip_msg_free(&m);
+ }
+
+ free(out);
+}
+
+/*
+ * Negative coverage: a compromised peer can put arbitrary bytes on the wire,
+ * so the receive path must reject every malformed frame with a protocol error
+ * (ip_get == -1) rather than crashing, leaking, or accepting bad data. These
+ * cases inject hand-built frames directly with imsg_composev(), bypassing the
+ * well-formed framing that ip_compose() guarantees.
+ */
+
+/*
+ * These cases inject and drain on one thread, so a single ip_flush() must not
+ * block waiting for a reader that cannot run. Enlarge the socket buffers so an
+ * entire (multi-chunk) message fits without back-pressure.
+ */
+static void
+setbuf_big(int fd)
+{
+ int sz = 1 << 20;
+
+ (void)setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sz, sizeof(sz));
+ (void)setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &sz, sizeof(sz));
+}
+
+static void
+mkpair(struct imsgproto *s, struct imsgproto *r)
+{
+ int sv[2];
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+ setbuf_big(sv[0]);
+ setbuf_big(sv[1]);
+ if (ip_init(s, sv[1]) == -1 || ip_init(r, sv[0]) == -1)
+ err(1, "ip_init");
+}
+
+static void
+delpair(struct imsgproto *s, struct imsgproto *r)
+{
+ int sfd, rfd;
+
+ sfd = ip_fd(s);
+ rfd = ip_fd(r);
+ ip_clear(s); /* must also release any in-progress reassembly */
+ ip_clear(r);
+ close(sfd);
+ close(rfd);
+}
+
+/* Inject one hand-built frame: optional chunk header + optional body bytes. */
+static void
+send_raw(struct imsgproto *s, uint32_t type, uint32_t id,
+ const struct fugu_chdr *chdr, const void *body, size_t bodylen)
+{
+ struct iovec iov[2];
+ int n = 0;
+
+ if (chdr != NULL) {
+ iov[n].iov_base = (void *)(uintptr_t)chdr;
+ iov[n].iov_len = sizeof(*chdr);
+ n++;
+ }
+ if (bodylen > 0) {
+ iov[n].iov_base = (void *)(uintptr_t)body;
+ iov[n].iov_len = bodylen;
+ n++;
+ }
+ if (imsg_composev(&s->ibuf, type, id, 0, -1, iov, n) == -1)
+ err(1, "imsg_composev");
+ if (imsgbuf_flush(&s->ibuf) == -1)
+ err(1, "imsgbuf_flush");
+}
+
+/* Drain already-buffered frames: returns ip_get status (1, 0 = EOF, -1). */
+static int
+recv_status(struct imsgproto *r)
+{
+ struct fugu_msg m;
+ int rc, n;
+
+ for (;;) {
+ if ((rc = ip_get(r, &m)) == 1) {
+ ip_msg_free(&m);
+ return (1);
+ }
+ if (rc == -1)
+ return (-1);
+ if ((n = ip_read(r)) == 0)
+ return (0);
+ if (n == -1)
+ return (-1);
+ }
+}
+
+/* One read pass, then drain: returns completed-message count, or -1 on error. */
+static int
+drain_once(struct imsgproto *r)
+{
+ struct fugu_msg m;
+ int rc, completed = 0;
+
+ if (ip_read(r) <= 0)
+ return (-1);
+ for (;;) {
+ rc = ip_get(r, &m);
+ if (rc == 1) {
+ ip_msg_free(&m);
+ completed++;
+ continue;
+ }
+ if (rc == -1)
+ return (-1);
+ break;
+ }
+ return (completed);
+}
+
+static void
+neg_cases(void)
+{
+ struct imsgproto s, r;
+ struct fugu_chdr c;
+ struct fugu_msg m;
+ uint8_t body[64];
+ uint8_t big[40000];
+ size_t k;
+ int i, rc;
+
+ memset(body, 0xab, sizeof(body));
+
+ /* A. payload shorter than the chunk header */
+ mkpair(&s, &r);
+ send_raw(&s, M_SUBMIT, EP_UI, NULL, body, 4);
+ CHECK(recv_status(&r) == -1, "reject: short frame");
+ delpair(&s, &r);
+
+ /* B. advertised total exceeds the IP_MAXMSG cap */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 1; c.total = IP_MAXMSG + 1; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 4);
+ CHECK(recv_status(&r) == -1, "reject: oversize total");
+ delpair(&s, &r);
+
+ /* C. chunk runs past the advertised total */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 1; c.total = 10; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 20);
+ CHECK(recv_status(&r) == -1, "reject: chunk past end");
+ delpair(&s, &r);
+
+ /* D. first chunk does not start at offset 0 */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 1; c.total = 100; c.off = 5;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 10);
+ CHECK(recv_status(&r) == -1, "reject: missing start chunk");
+ delpair(&s, &r);
+
+ /* E. empty chunk inside a non-empty message */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 1; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, NULL, 0);
+ CHECK(recv_status(&r) == -1, "reject: empty chunk in non-empty msg");
+ delpair(&s, &r);
+
+ /* F1. continuation advertising a different total */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+ c.total = 200; c.off = 50;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+ CHECK(recv_status(&r) == -1, "reject: continuation total mismatch");
+ delpair(&s, &r);
+
+ /* F2. continuation at an out-of-order offset (a gap) */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+ c.off = 70; /* expected 50 */
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 30);
+ CHECK(recv_status(&r) == -1, "reject: continuation offset gap");
+ delpair(&s, &r);
+
+ /* F3. continuation changing the logical message type */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+ c.off = 50;
+ send_raw(&s, M_DELTA, EP_UI, &c, body, 50);
+ CHECK(recv_status(&r) == -1, "reject: continuation type mismatch");
+ delpair(&s, &r);
+
+ /* G. a flood of stalled partials must not wedge the table: the oldest is
+ * evicted and a fresh multi-chunk message still completes. */
+ mkpair(&s, &r);
+ for (i = 0; i < IP_MAXASM; i++) { /* fill every slot, none completed */
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 100 + i; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 60);
+ }
+ CHECK(drain_once(&r) == 0,
+ "eviction: stalled partials accepted without error");
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 300; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 60); /* evicts an old partial */
+ c.off = 60;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 40); /* completes msgid 300 */
+ rc = recv_one(&r, &m);
+ CHECK(rc == 1, "eviction: fresh message completes despite full table");
+ if (rc == 1) {
+ CHECK(m.len == 100, "eviction: fresh message length %zu", m.len);
+ ip_msg_free(&m);
+ }
+ delpair(&s, &r);
+
+ /* H. channel stays usable after a protocol error frees its slot */
+ mkpair(&s, &r);
+ memset(&c, 0, sizeof(c));
+ c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+ c.off = 70;
+ send_raw(&s, M_SUBMIT, EP_UI, &c, body, 30);
+ CHECK(recv_status(&r) == -1, "recovery: protocol error observed");
+ for (k = 0; k < sizeof(big); k++)
+ big[k] = (uint8_t)(k * 7u + 1u);
+ CHECK(ip_compose(&s, M_SUBMIT, EP_NET, EP_UI, big, sizeof(big)) == 0,
+ "recovery: compose well-formed msg");
+ CHECK(ip_flush(&s) == 0, "recovery: flush well-formed msg");
+ CHECK(recv_one(&r, &m) == 1, "recovery: well-formed msg received");
+ if (m.len == sizeof(big)) {
+ CHECK(memcmp(m.data, big, sizeof(big)) == 0,
+ "recovery: payload intact after prior error");
+ ip_msg_free(&m);
+ } else {
+ CHECK(0, "recovery: len=%zu want %zu", m.len, sizeof(big));
+ }
+ delpair(&s, &r);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "imsg_test: TIMED OUT (deadlock?)\n");
+ _exit(97);
+}
+
+int
+main(void)
+{
+ struct imsgproto ip;
+ size_t sizes[] = {
+ 0, 1, 2, 100, 4096,
+ (size_t)IP_MAXCHUNK - 1, (size_t)IP_MAXCHUNK,
+ (size_t)IP_MAXCHUNK + 1, 2 * (size_t)IP_MAXCHUNK,
+ 50000, 200000, 1000003
+ };
+ size_t i;
+ pid_t pid;
+ int sv[2], status;
+
+ signal(SIGALRM, on_alarm);
+ alarm(60); /* a deadlock should fail the test, not hang CI */
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+ if ((pid = fork()) == -1)
+ err(1, "fork");
+ if (pid == 0) {
+ close(sv[0]);
+ child_main(sv[1]);
+ _exit(0); /* not reached */
+ }
+ close(sv[1]);
+
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ for (i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++)
+ roundtrip(&ip, sizes[i], (uint32_t)(i + 1));
+
+ CHECK(ip_compose(&ip, M_SHUTDOWN, EP_NET, EP_UI, NULL, 0) == 0,
+ "shutdown compose");
+ CHECK(ip_flush(&ip) == 0, "shutdown flush");
+ ip_clear(&ip);
+
+ if (waitpid(pid, &status, 0) == -1)
+ err(1, "waitpid");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "child exited abnormally (status %d)", status);
+
+ neg_cases();
+
+ printf("imsg_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + f534fdd41586c8d8c11fc41992545f71391a5cce (mode 644)
--- /dev/null
+++ regress/json/Makefile
+# Unit tests for the JSON emitter + jsmn wrapper (src/common/json.c).
+
+PROG= json_test
+SRCS= json_test.c json.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-json_test
+
+run-json_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6ec4a6175a2664dfce9b49e00c77dd9aaf547401 (mode 644)
--- /dev/null
+++ regress/json/json_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "json.h"
+
+static int fails;
+static int checks;
+
+#define CHECK(cond, msg) do { \
+ checks++; \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", \
+ __func__, __LINE__, (msg)); \
+ fails++; \
+ } \
+} while (0)
+
+static void
+check_str(const char *func, int line, const char *got, const char *want,
+ const char *msg)
+{
+ checks++;
+ if (strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s:%d: %s\n got: %s\n want: %s\n",
+ func, line, msg, got, want);
+ fails++;
+ }
+}
+
+#define CHECK_STR(got, want, msg) \
+ check_str(__func__, __LINE__, (got), (want), (msg))
+
+/* Finish an emitted buffer as a C string. */
+static const char *
+done(struct buf *b)
+{
+ buf_putc(b, '\0');
+ return (b->data);
+}
+
+static void
+test_emit_simple(void)
+{
+ struct buf b;
+ struct json_writer w;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "model", "claude-sonnet-4-6");
+ json_kv_int(&w, "max_tokens", 8192);
+ json_kv_bool(&w, "stream", 1);
+ json_object_end(&w);
+ CHECK_STR(done(&b),
+ "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":8192,"
+ "\"stream\":true}", "emit simple object");
+ buf_free(&b);
+}
+
+static void
+test_emit_nested(void)
+{
+ struct buf b;
+ struct json_writer w;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_key(&w, "messages");
+ json_array_start(&w);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "user");
+ json_kv_string(&w, "content", "hi");
+ json_object_end(&w);
+ json_array_end(&w);
+ json_object_end(&w);
+ CHECK_STR(done(&b),
+ "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}",
+ "emit nested object/array");
+ buf_free(&b);
+}
+
+static void
+test_emit_empty(void)
+{
+ struct buf b;
+ struct json_writer w;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_key(&w, "a");
+ json_array_start(&w);
+ json_array_end(&w);
+ json_key(&w, "b");
+ json_object_start(&w);
+ json_object_end(&w);
+ json_object_end(&w);
+ CHECK_STR(done(&b), "{\"a\":[],\"b\":{}}",
+ "emit empty containers + separators");
+ buf_free(&b);
+}
+
+static void
+test_escaping(void)
+{
+ struct buf b;
+ struct json_writer w;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ /* a " b \ c <LF> <TAB> <0x01> */
+ json_string(&w, "a\"b\\c\n\t\001");
+ CHECK_STR(done(&b), "\"a\\\"b\\\\c\\n\\t\\u0001\"",
+ "string escaping");
+ buf_free(&b);
+}
+
+static void
+test_numbers(void)
+{
+ struct buf b;
+ struct json_writer w;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_array_start(&w);
+ json_int(&w, -5);
+ json_int(&w, 9223372036854775807LL);
+ json_null(&w);
+ json_array_end(&w);
+ CHECK_STR(done(&b), "[-5,9223372036854775807,null]",
+ "integers and null in array");
+ buf_free(&b);
+}
+
+/* Emit {"s": input}, parse it back, and confirm the value round-trips. */
+static void
+roundtrip_one(const char *input)
+{
+ struct buf b;
+ struct json_writer w;
+ struct json_doc d;
+ char *got;
+ int v;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "s", input);
+ json_object_end(&w);
+ buf_putc(&b, '\0');
+
+ CHECK(json_parse(&d, b.data, strlen(b.data)) == 0, "roundtrip parse");
+ v = json_obj_get(&d, 0, "s");
+ CHECK(v > 0, "roundtrip find s");
+ got = json_tok_strdup(&d, v);
+ CHECK_STR(got, input, "roundtrip value");
+ free(got);
+ json_doc_free(&d);
+ buf_free(&b);
+}
+
+static void
+test_roundtrip(void)
+{
+ roundtrip_one("hello");
+ roundtrip_one("");
+ roundtrip_one("a\"b\\c\n\t\001");
+ roundtrip_one("caf\xc3\xa9 \xe2\x9c\x93"); /* UTF-8 passthrough */
+ roundtrip_one("line1\nline2\ttabbed \"quoted\" \\slash\\");
+}
+
+static void
+test_unicode_unescape(void)
+{
+ const char *js = "{\"a\":\"\\u0041\\u00e9\",\"b\":\"\\ud83d\\ude00\"}";
+ struct json_doc d;
+ char *a, *bb;
+
+ CHECK(json_parse(&d, js, strlen(js)) == 0, "unicode parse");
+ a = json_tok_strdup(&d, json_obj_get(&d, 0, "a"));
+ bb = json_tok_strdup(&d, json_obj_get(&d, 0, "b"));
+ CHECK_STR(a, "\x41\xc3\xa9", "\\u BMP decode (A + e-acute)");
+ CHECK_STR(bb, "\xf0\x9f\x98\x80", "\\u surrogate pair decode (emoji)");
+ free(a);
+ free(bb);
+ json_doc_free(&d);
+}
+
+/*
+ * json_tok_strdup_n must be binary-safe: a JSON backslash-u-0000 escape
+ * decodes to a real NUL byte inside the returned heap string. The length-
+ * aware variant must report the true decoded byte count rather than stopping
+ * at the first embedded NUL (which is what strlen would do).
+ */
+static void
+test_strdup_n_embedded_nul(void)
+{
+ const char *js = "{\"s\":\"a\\u0000b\"}"; /* decoded: a\0b */
+ struct json_doc d;
+ char *got;
+ size_t n;
+ int v;
+
+ CHECK(json_parse(&d, js, strlen(js)) == 0, "embedded NUL parse");
+ v = json_obj_get(&d, 0, "s");
+ CHECK(v > 0, "embedded NUL find s");
+
+ got = json_tok_strdup_n(&d, v, &n);
+ CHECK(n == 3, "decoded length is true byte count (3), not strlen (1)");
+ CHECK(strlen(got) == 1, "strlen would silently truncate at first NUL");
+ CHECK(got[0] == 'a' && got[1] == '\0' && got[2] == 'b',
+ "decoded bytes are a, NUL, b");
+ CHECK(got[3] == '\0', "trailing NUL terminator present after content");
+ free(got);
+
+ /* lenp == NULL contract: must not crash, must still return the data. */
+ got = json_tok_strdup_n(&d, v, NULL);
+ CHECK(got != NULL, "strdup_n with NULL lenp returns a buffer");
+ CHECK(got[0] == 'a' && got[1] == '\0' && got[2] == 'b',
+ "NULL-lenp call still decodes content correctly");
+ free(got);
+
+ json_doc_free(&d);
+}
+
+static void
+test_lookup(void)
+{
+ const char *js =
+ "{\"type\":\"message\",\"content\":[{\"type\":\"text\","
+ "\"text\":\"hi\"}],\"usage\":{\"input_tokens\":10}}";
+ struct json_doc d;
+ char *type;
+ int content, usage, in;
+
+ CHECK(json_parse(&d, js, strlen(js)) == 0, "lookup parse");
+
+ type = json_tok_strdup(&d, json_obj_get(&d, 0, "type"));
+ CHECK_STR(type, "message", "top-level string member");
+ free(type);
+
+ content = json_obj_get(&d, 0, "content");
+ CHECK(content > 0 && json_tok_type(&d, content) == JSON_ARRAY,
+ "content is array");
+ CHECK(json_tok_size(&d, content) == 1, "content has one element");
+
+ usage = json_obj_get(&d, 0, "usage");
+ CHECK(usage > 0 && json_tok_type(&d, usage) == JSON_OBJECT,
+ "usage is object");
+ in = json_obj_get(&d, usage, "input_tokens");
+ CHECK(in > 0 && json_tok_type(&d, in) == JSON_PRIMITIVE,
+ "nested primitive");
+ CHECK(json_tok_streq(&d, in, "10"), "nested primitive value");
+
+ CHECK(json_obj_get(&d, 0, "missing") == -1, "missing key -> -1");
+ json_doc_free(&d);
+}
+
+static void
+test_malformed(void)
+{
+ struct json_doc d;
+
+ CHECK(json_parse(&d, "{", 1) == -1, "truncated object rejected");
+ CHECK(json_parse(&d, "[1,2", 4) == -1, "truncated array rejected");
+}
+
+int
+main(void)
+{
+ test_emit_simple();
+ test_emit_nested();
+ test_emit_empty();
+ test_escaping();
+ test_numbers();
+ test_roundtrip();
+ test_unicode_unescape();
+ test_strdup_n_embedded_nul();
+ test_lookup();
+ test_malformed();
+
+ printf("%d checks, %d failures\n", checks, fails);
+ return (fails != 0);
+}
blob - /dev/null
blob + 6529164e01ad315ef1200790da4242511de40b00 (mode 644)
--- /dev/null
+++ regress/md/Makefile
+# Unit test for the markdown-to-styled-text renderer (src/common/mdrender.c).
+
+PROG= md_test
+SRCS= md_test.c mdrender.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-md_test
+
+run-md_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 4e67fe290e99c14dcadb545140dce30a53efcf4c (mode 644)
--- /dev/null
+++ regress/md/md_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <wchar.h>
+
+#include "mdrender.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* Map a flag-spec character to the MD_* bitmask it stands for. */
+static int
+flagbits(char c)
+{
+ switch (c) {
+ case 'b': return (MD_BOLD);
+ case 'i': return (MD_ITALIC);
+ case 'c': return (MD_CODE);
+ case 'd': return (MD_CODEBLOCK);
+ case 'h': return (MD_HEADING | MD_BOLD);
+ case 'B': return (MD_BOLD | MD_ITALIC); /* nested bold+italic */
+ default: return (0); /* '.' = unstyled */
+ }
+}
+
+/*
+ * Render `in` and compare both the output text and (when flagspec != NULL) the
+ * per-character style flags. `flagspec` is an ASCII string the same length as
+ * `want`, one char per output char (see flagbits()).
+ */
+static void
+md(const char *label, const wchar_t *in, int ascii, const wchar_t *want,
+ const char *flagspec)
+{
+ wchar_t *out;
+ int *st;
+ size_t n, wn = wcslen(want), i;
+
+ n = md_render(in, wcslen(in), ascii, &out, &st);
+ CHECK(n == wn, "%s: length got=%zu want=%zu", label, n, wn);
+ if (n == wn) {
+ for (i = 0; i < n; i++) {
+ CHECK(out[i] == want[i],
+ "%s: char[%zu] got=U+%04X want=U+%04X", label, i,
+ (unsigned)out[i], (unsigned)want[i]);
+ if (flagspec != NULL)
+ CHECK(st[i] == flagbits(flagspec[i]),
+ "%s: flag[%zu] got=0x%02x want=0x%02x",
+ label, i, st[i],
+ flagbits(flagspec[i]));
+ }
+ }
+ free(out);
+ free(st);
+}
+
+int
+main(void)
+{
+ /* plain text passes through unchanged */
+ md("plain", L"hello world", 0, L"hello world", "...........");
+ md("multiline", L"a\nb", 0, L"a\nb", "...");
+
+ /* inline emphasis: markers removed, styles applied */
+ md("bold", L"a **b** c", 0, L"a b c", "..b..");
+ md("italic-star", L"a *b* c", 0, L"a b c", "..i..");
+ md("italic-underscore", L"_x_", 0, L"x", "i");
+ md("bold-underscore", L"__x__", 0, L"x", "b");
+ md("inline-code", L"a `b` c", 0, L"a b c", "..c..");
+ md("nested", L"**a _b_ c**", 0, L"a b c", "bbBbb");
+
+ /* unmatched / escaped markers stay literal */
+ md("loose-star", L"a * b", 0, L"a * b", ".....");
+ md("escape", L"\\*x\\*", 0, L"*x*", "...");
+
+ /* headings: hashes stripped, text bold */
+ md("h1", L"# Title", 0, L"Title", "hhhhh");
+ md("h3", L"### Hi", 0, L"Hi", "hh");
+ md("not-heading", L"#tag", 0, L"#tag", "....");
+
+ /* bullets: marker -> glyph (Unicode vs ASCII) */
+ md("bullet-utf8", L"- item", 0, L"• item", "......");
+ md("bullet-ascii", L"- item", 1, L"- item", "......");
+ md("bullet-star", L"* item", 0, L"• item", "......");
+ md("bullet-not", L"*x*", 0, L"x", "i"); /* no space -> italic */
+ md("numbered", L"1. first", 0, L"1. first", "........");
+
+ /* fenced code: fence lines dropped, body dimmed, no inline parse */
+ md("fence", L"```\nco**de**\n```", 0, L"co**de**\n", "dddddddd.");
+
+ /* GFM tables: the |---| delimiter row is dropped, rows kept and styled */
+ md("table-basic", L"| A | B |\n|---|---|\n| 1 | 2 |", 0,
+ L"| A | B |\n| 1 | 2 |", NULL);
+ md("table-align", L"| A | B |\n| :-- | --: |\n| 1 | 2 |", 0,
+ L"| A | B |\n| 1 | 2 |", NULL);
+ md("table-styled-cell", L"| **a** |\n|---|", 0, L"| a |\n", "..b...");
+ md("escaped-pipe", L"a \\| b", 0, L"a | b", ".....");
+ /* not delimiter rows: a thematic break and ordinary piped text survive */
+ md("rule-not-table", L"---", 0, L"---", "...");
+ md("text-not-table", L"a | b", 0, L"a | b", ".....");
+
+ if (failures == 0)
+ printf("ok: %d checks passed\n", checks);
+ else
+ printf("FAILED: %d/%d checks\n", failures, checks);
+ return (failures == 0 ? 0 : 1);
+}
blob - /dev/null
blob + c9a6f39b385217dfbc3e880232c48bbfc3f33b47 (mode 644)
--- /dev/null
+++ regress/net/Makefile
+# Whole-worker integration test for the net worker (src/net/net_run.c).
+# Generates a throwaway self-signed cert at run time with openssl(1).
+
+PROG= net_test
+SRCS= net_test.c net_run.c net_api.c \
+ anthropic.c openai.c http.c sse.c json.c \
+ buf.c util.c log.c imsgproto.c tlsconn.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+NETDIR= ${.CURDIR}/../../src/net
+CFLAGS+= -I${COMMONDIR} -I${NETDIR}
+.PATH: ${COMMONDIR} ${NETDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS= run-net_test
+
+run-net_test: ${PROG}
+ @td=`mktemp -d /tmp/fugu-net.XXXXXX`; \
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout $$td/key.pem -out $$td/cert.pem \
+ -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+ ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+ rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 57a5ea561045f022b319f604b5a8c3003093ab0d (mode 644)
--- /dev/null
+++ regress/net/net_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Whole-worker integration of net_loop over three processes:
+ * - H (this process) plays parent + UI: drives the imsg channel and checks
+ * the streamed-back messages;
+ * - N is the net worker: ip_init + net_loop (pledges, connects over TLS);
+ * - S is a libtls stub server on 127.0.0.1 serving a canned response.
+ *
+ * H tells N (via M_CONFIG) to talk to S with S's self-signed cert as the CA, so
+ * the worker exercises real certificate verification. H then submits a turn
+ * and asserts the M_DELTA/M_USAGE/M_TURN_DONE it receives. The scenario runs
+ * twice -- once for the Anthropic provider, once for OpenAI -- and for OpenAI
+ * the stub additionally asserts the on-wire request (path, Bearer auth, body).
+ *
+ * argv: <cert.pem> <key.pem> (generated by the Makefile run target).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "net_api.h"
+#include "net_run.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* The Anthropic provider's streamed reply: "pong", then end_turn. */
+static const char *const ANTHROPIC_SSE =
+ "event: message_start\n"
+ "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+ "{\"input_tokens\":5,\"output_tokens\":1}}}\n"
+ "\n"
+ "event: content_block_start\n"
+ "data: {\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"pong\"}}\n"
+ "\n"
+ "event: content_block_stop\n"
+ "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+ "\n"
+ "event: message_delta\n"
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n";
+
+/* The OpenAI provider's equivalent: bare data lines and a [DONE] sentinel. */
+static const char *const OPENAI_SSE =
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+ "\"content\":\"\"}}]}\n"
+ "\n"
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"pong\"}}]}\n"
+ "\n"
+ "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":"
+ "\"stop\"}]}\n"
+ "\n"
+ "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,"
+ "\"completion_tokens\":4}}\n"
+ "\n"
+ "data: [DONE]\n"
+ "\n";
+
+/* The /v1/models reply (both providers use {"data":[{"id":...}]}). */
+static const char *const MODELS_JSON =
+ "{\"object\":\"list\",\"data\":[{\"id\":\"model-alpha\"},"
+ "{\"id\":\"model-beta\"},{\"id\":\"model-gamma\"}]}";
+
+/* ---- stub TLS server (process S) ---- */
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+ buf_printf(b, "%zx\r\n", n);
+ buf_append(b, data, n);
+ buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tls_write(t, p + off, len - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n < 0)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key, const char *sse_body,
+ int assert_openai, const char *want_path)
+{
+ struct tls_config *cfg;
+ struct tls *srv, *cctx;
+ struct buf resp;
+ char req[4096];
+ char reqline[256];
+ ssize_t n;
+ int cfd;
+ size_t sl, half;
+
+ if ((cfd = accept(lfd, NULL, NULL)) == -1)
+ _exit(20);
+ if ((cfg = tls_config_new()) == NULL)
+ _exit(21);
+ if (tls_config_set_cert_file(cfg, cert) == -1 ||
+ tls_config_set_key_file(cfg, key) == -1)
+ _exit(22);
+ if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+ _exit(23);
+ if (tls_accept_socket(srv, &cctx, cfd) == -1)
+ _exit(24);
+ do {
+ n = tls_handshake(cctx);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n == -1)
+ _exit(25);
+ do {
+ n = tls_read(cctx, req, sizeof(req) - 1);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n < 0)
+ _exit(27);
+ req[n] = '\0';
+
+ /* A GET is the /model fetch: assert path+auth and serve a models list. */
+ if (strncmp(req, "GET ", 4) == 0) {
+ if (strstr(req, "GET /v1/models ") == NULL)
+ _exit(44); /* wrong models path */
+ if (strstr(req, "x-api-key: test-key") == NULL)
+ _exit(45); /* auth header missing */
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: application/json\r\n");
+ buf_printf(&resp, "Content-Length: %zu\r\n\r\n",
+ strlen(MODELS_JSON));
+ buf_puts_cstr(&resp, MODELS_JSON);
+ if (tls_write_all(cctx, resp.data, resp.len) == -1)
+ _exit(26);
+ buf_free(&resp);
+ tls_close(cctx);
+ _exit(0);
+ }
+
+ /* The first record carries the request line, headers, and body start. */
+ if (assert_openai) {
+ snprintf(reqline, sizeof(reqline), "POST %s ", want_path);
+ if (strstr(req, reqline) == NULL)
+ _exit(40); /* wrong endpoint path */
+ if (strstr(req, "authorization: Bearer test-key") == NULL)
+ _exit(41); /* wrong auth scheme/value */
+ if (strstr(req, "\"stream\":true") == NULL)
+ _exit(42); /* body not an OpenAI request */
+ if (strstr(req, "\"role\":\"system\"") == NULL)
+ _exit(43); /* built-in default system prompt missing */
+ }
+
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+ buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+ sl = strlen(sse_body);
+ half = sl / 2;
+ add_chunk(&resp, sse_body, half);
+ add_chunk(&resp, sse_body + half, sl - half);
+ buf_puts_cstr(&resp, "0\r\n\r\n");
+ if (tls_write_all(cctx, resp.data, resp.len) == -1)
+ _exit(26);
+ buf_free(&resp);
+ tls_close(cctx);
+ _exit(0);
+}
+
+/* ---- harness imsg helpers ---- */
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+ int r, n;
+
+ for (;;) {
+ if ((r = ip_get(ip, m)) == 1)
+ return (1);
+ if (r == -1)
+ return (-1);
+ if ((n = ip_read(ip)) <= 0)
+ return (0);
+ }
+}
+
+static void
+send_msg(struct imsgproto *ip, uint32_t type, const void *data, size_t len)
+{
+ if (ip_compose(ip, type, EP_NET, EP_PARENT, data, len) == -1 ||
+ ip_flush(ip) == -1)
+ errx(1, "harness: send imsg");
+}
+
+/* M_SECRET to net: a uint32 provider index (host order) then the key bytes. */
+static void
+send_secret(struct imsgproto *ip, uint32_t idx, const char *key)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_append(&b, &idx, sizeof(idx));
+ buf_append(&b, key, strlen(key));
+ send_msg(ip, M_SECRET, b.data, b.len);
+ buf_free(&b);
+}
+
+/* A loopback TCP listener on an ephemeral port (written into portstr). */
+static int
+make_listener(char *portstr, size_t cap)
+{
+ struct sockaddr_in sa;
+ socklen_t slen;
+ int lfd, one = 1;
+
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 1) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ snprintf(portstr, cap, "%d", ntohs(sa.sin_port));
+ return (lfd);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "net_test: TIMED OUT\n");
+ _exit(97);
+}
+
+/*
+ * Run one full turn against a freshly-forked stub server and net worker.
+ * provider is NULL for Anthropic or "openai"; the streamed text is always
+ * "pong" with usage output_tokens=4 and stop end_turn, regardless of provider.
+ */
+static void
+run_scenario(const char *cert, const char *key, const char *provider,
+ const char *cfg_path, const char *sse_body, int assert_openai,
+ const char *want_path, const char *label)
+{
+ struct imsgproto ip;
+ struct fugu_msg m;
+ struct buf text, cfg;
+ struct sockaddr_in sa;
+ socklen_t slen;
+ pid_t sp, np;
+ char portstr[16];
+ char stop[64];
+ int lfd, sv[2], one = 1, status, port;
+ int got_usage = 0, got_done = 0;
+
+ /* TCP listener for the stub server on an ephemeral 127.0.0.1 port */
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 1) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+
+ /* S: stub server */
+ if ((sp = fork()) == -1)
+ err(1, "fork");
+ if (sp == 0) {
+ close(sv[0]);
+ close(sv[1]);
+ stub_server(lfd, cert, key, sse_body, assert_openai, want_path);
+ _exit(0);
+ }
+
+ /* N: net worker */
+ if ((np = fork()) == -1)
+ err(1, "fork");
+ if (np == 0) {
+ struct imsgproto nip;
+
+ close(sv[0]);
+ close(lfd);
+ if (ip_init(&nip, sv[1]) == -1)
+ _exit(30);
+ net_loop(&nip);
+ _exit(0);
+ }
+
+ /* H: parent + UI */
+ close(sv[1]);
+ close(lfd);
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "%s: worker READY",
+ label);
+ ip_msg_free(&m);
+
+ /* Config first (it allocates the provider slots), then the keyed secret. */
+ buf_init(&cfg);
+ buf_printf(&cfg, "{\"max_tokens\":64,\"cafile\":\"%s\",\"active\":0,"
+ "\"providers\":[{\"name\":\"default\",", cert);
+ if (provider != NULL) {
+ buf_printf(&cfg,
+ "\"type\":\"%s\",\"model\":\"gpt-4o-mini\","
+ "\"host\":\"localhost\",\"port\":\"%s\"", provider, portstr);
+ if (cfg_path != NULL)
+ buf_printf(&cfg, ",\"path\":\"%s\"", cfg_path);
+ } else
+ buf_printf(&cfg,
+ "\"type\":\"anthropic\",\"model\":\"claude-sonnet-4-6\","
+ "\"host\":\"localhost\",\"port\":\"%s\"", portstr);
+ buf_puts_cstr(&cfg, "}]}");
+ send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+ buf_free(&cfg);
+
+ send_secret(&ip, 0, "test-key");
+
+ send_msg(&ip, M_SUBMIT,
+ "[{\"role\":\"user\",\"content\":\"ping\"}]",
+ strlen("[{\"role\":\"user\",\"content\":\"ping\"}]"));
+
+ buf_init(&text);
+ stop[0] = '\0';
+ for (;;) {
+ if (recv_msg(&ip, &m) != 1) {
+ CHECK(0, "%s: lost worker before TURN_DONE", label);
+ break;
+ }
+ if (m.type == M_DELTA)
+ buf_append(&text, m.data, m.len);
+ else if (m.type == M_USAGE) {
+ char ub[256];
+ size_t k = m.len < sizeof(ub) - 1 ? m.len : sizeof(ub) - 1;
+
+ memcpy(ub, m.data, k);
+ ub[k] = '\0';
+ if (strstr(ub, "\"output_tokens\":4") != NULL)
+ got_usage = 1;
+ } else if (m.type == M_TURN_DONE) {
+ size_t k = m.len < sizeof(stop) - 1 ? m.len : sizeof(stop) - 1;
+
+ memcpy(stop, m.data, k);
+ stop[k] = '\0';
+ got_done = 1;
+ ip_msg_free(&m);
+ break;
+ } else if (m.type == M_ERROR) {
+ CHECK(0, "%s: worker error: %.*s", label, (int)m.len,
+ (char *)m.data);
+ ip_msg_free(&m);
+ break;
+ }
+ ip_msg_free(&m);
+ }
+
+ buf_terminate(&text);
+ CHECK(strcmp(text.data, "pong") == 0, "%s: assembled text '%s'", label,
+ text.data);
+ CHECK(got_usage, "%s: usage output_tokens=4 received", label);
+ CHECK(got_done, "%s: TURN_DONE received", label);
+ CHECK(strcmp(stop, "end_turn") == 0, "%s: stop reason '%s'", label, stop);
+
+ send_msg(&ip, M_SHUTDOWN, NULL, 0);
+
+ if (waitpid(np, &status, 0) == -1)
+ err(1, "waitpid net");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "%s: net worker exit %d", label, status);
+ if (waitpid(sp, &status, 0) == -1)
+ err(1, "waitpid stub");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "%s: stub server exit %d (40=path 41=auth 42=body 43=system)", label,
+ status);
+
+ buf_free(&text);
+}
+
+/*
+ * /model: drive M_MODELS through the real net worker. The worker GETs the
+ * provider's /v1/models from the stub (which asserts the path + auth) and
+ * replies M_MODELS with the parsed, newline-separated id list.
+ */
+static void
+run_models_scenario(const char *cert, const char *key)
+{
+ struct imsgproto ip;
+ struct fugu_msg m;
+ struct buf cfg;
+ struct sockaddr_in sa;
+ socklen_t slen;
+ pid_t sp, np;
+ char portstr[16];
+ char out[4096];
+ int lfd, sv[2], one = 1, status, port, got = 0;
+
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 1) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+
+ if ((sp = fork()) == -1)
+ err(1, "fork");
+ if (sp == 0) {
+ close(sv[0]);
+ close(sv[1]);
+ stub_server(lfd, cert, key, ANTHROPIC_SSE, 0, NULL);
+ _exit(0);
+ }
+ if ((np = fork()) == -1)
+ err(1, "fork");
+ if (np == 0) {
+ struct imsgproto nip;
+
+ close(sv[0]);
+ close(lfd);
+ if (ip_init(&nip, sv[1]) == -1)
+ _exit(30);
+ net_loop(&nip);
+ _exit(0);
+ }
+
+ close(sv[1]);
+ close(lfd);
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "models: worker READY");
+ ip_msg_free(&m);
+ buf_init(&cfg);
+ buf_printf(&cfg, "{\"max_tokens\":64,\"cafile\":\"%s\",\"active\":0,"
+ "\"providers\":[{\"name\":\"default\",\"type\":\"anthropic\","
+ "\"model\":\"claude-sonnet-4-6\",\"host\":\"localhost\","
+ "\"port\":\"%s\"}]}", cert, portstr);
+ send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+ buf_free(&cfg);
+ send_secret(&ip, 0, "test-key");
+
+ send_msg(&ip, M_MODELS, NULL, 0);
+
+ out[0] = '\0';
+ for (;;) {
+ if (recv_msg(&ip, &m) != 1) {
+ CHECK(0, "models: lost worker before reply");
+ break;
+ }
+ if (m.type == M_MODELS) {
+ size_t k = m.len < sizeof(out) - 1 ? m.len : sizeof(out) - 1;
+
+ memcpy(out, m.data, k);
+ out[k] = '\0';
+ got = 1;
+ ip_msg_free(&m);
+ break;
+ }
+ if (m.type == M_ERROR) {
+ CHECK(0, "models: worker error: %.*s", (int)m.len,
+ (char *)m.data);
+ ip_msg_free(&m);
+ break;
+ }
+ ip_msg_free(&m);
+ }
+ CHECK(got, "models: M_MODELS reply received");
+ CHECK(strstr(out, "model-alpha") != NULL &&
+ strstr(out, "model-beta") != NULL &&
+ strstr(out, "model-gamma") != NULL,
+ "models: list parsed from /v1/models: '%s'", out);
+
+ send_msg(&ip, M_SHUTDOWN, NULL, 0);
+ if (waitpid(np, &status, 0) == -1)
+ err(1, "waitpid net");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "models: net worker exit %d", status);
+ if (waitpid(sp, &status, 0) == -1)
+ err(1, "waitpid stub");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "models: stub exit %d (44=path 45=auth)", status);
+}
+
+/*
+ * Two providers at once: /model must query each and tag every id "name\tmodel".
+ * Two anthropic providers ("default", "work") point at two stub endpoints; net
+ * fetches both, and the merged reply carries both providers' tagged ids.
+ */
+static void
+run_multi_models_scenario(const char *cert, const char *key)
+{
+ struct imsgproto ip;
+ struct fugu_msg m;
+ struct buf cfg;
+ pid_t sa_pid, sb_pid, np;
+ char pa[16], pb[16], out[8192];
+ int lfda, lfdb, sv[2], status, got = 0;
+
+ lfda = make_listener(pa, sizeof(pa));
+ lfdb = make_listener(pb, sizeof(pb));
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+
+ if ((sa_pid = fork()) == -1)
+ err(1, "fork");
+ if (sa_pid == 0) {
+ close(sv[0]); close(sv[1]); close(lfdb);
+ stub_server(lfda, cert, key, ANTHROPIC_SSE, 0, NULL);
+ _exit(0);
+ }
+ if ((sb_pid = fork()) == -1)
+ err(1, "fork");
+ if (sb_pid == 0) {
+ close(sv[0]); close(sv[1]); close(lfda);
+ stub_server(lfdb, cert, key, ANTHROPIC_SSE, 0, NULL);
+ _exit(0);
+ }
+ if ((np = fork()) == -1)
+ err(1, "fork");
+ if (np == 0) {
+ struct imsgproto nip;
+
+ close(sv[0]); close(lfda); close(lfdb);
+ if (ip_init(&nip, sv[1]) == -1)
+ _exit(30);
+ net_loop(&nip);
+ _exit(0);
+ }
+
+ close(sv[1]); close(lfda); close(lfdb);
+ if (ip_init(&ip, sv[0]) == -1)
+ err(1, "ip_init");
+
+ CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "multi: worker READY");
+ ip_msg_free(&m);
+
+ buf_init(&cfg);
+ buf_printf(&cfg, "{\"cafile\":\"%s\",\"active\":0,\"providers\":["
+ "{\"name\":\"default\",\"type\":\"anthropic\",\"host\":\"localhost\","
+ "\"port\":\"%s\"},"
+ "{\"name\":\"work\",\"type\":\"anthropic\",\"host\":\"localhost\","
+ "\"port\":\"%s\"}]}", cert, pa, pb);
+ send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+ buf_free(&cfg);
+ send_secret(&ip, 0, "test-key");
+ send_secret(&ip, 1, "test-key");
+
+ send_msg(&ip, M_MODELS, NULL, 0);
+ out[0] = '\0';
+ for (;;) {
+ if (recv_msg(&ip, &m) != 1) {
+ CHECK(0, "multi: lost worker before reply");
+ break;
+ }
+ if (m.type == M_MODELS) {
+ size_t k = m.len < sizeof(out) - 1 ? m.len : sizeof(out) - 1;
+
+ memcpy(out, m.data, k);
+ out[k] = '\0';
+ got = 1;
+ ip_msg_free(&m);
+ break;
+ }
+ if (m.type == M_ERROR) {
+ CHECK(0, "multi: worker error: %.*s", (int)m.len,
+ (char *)m.data);
+ ip_msg_free(&m);
+ break;
+ }
+ ip_msg_free(&m);
+ }
+ CHECK(got, "multi: M_MODELS reply received");
+ CHECK(strstr(out, "default\tmodel-alpha") != NULL,
+ "multi: default provider's ids are tagged: '%s'", out);
+ CHECK(strstr(out, "work\tmodel-beta") != NULL,
+ "multi: second provider's ids are tagged: '%s'", out);
+
+ send_msg(&ip, M_SHUTDOWN, NULL, 0);
+ if (waitpid(np, &status, 0) == -1)
+ err(1, "waitpid net");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "multi: net worker exit %d", status);
+ if (waitpid(sa_pid, &status, 0) == -1 || waitpid(sb_pid, &status, 0) == -1)
+ err(1, "waitpid stub");
+}
+
+int
+main(int argc, char *argv[])
+{
+ if (argc != 3)
+ errx(1, "usage: %s cert.pem key.pem", argv[0]);
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(60);
+
+ /* tools_schema(): a valid JSON array, with the web tools gated by the flag. */
+ {
+ struct buf tb;
+ struct json_doc jd;
+
+ buf_init(&tb);
+ tools_schema(&tb, 1, 0, NULL);
+ CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+ jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+ "tools_schema(web=1) is a valid JSON array");
+ /* Match the tool DEFINITION, not a bare word: a base tool's
+ * description may mention the web tools by name (shell does). */
+ CHECK(strstr(tb.data, "\"name\":\"web_search\"") != NULL &&
+ strstr(tb.data, "\"name\":\"web_fetch\"") != NULL,
+ "tools_schema(web=1) includes the web tools");
+ CHECK(strstr(tb.data, "\"name\":\"http_request\"") == NULL,
+ "tools_schema(http=0) omits the http_request tool");
+ json_doc_free(&jd);
+
+ buf_reset(&tb);
+ tools_schema(&tb, 0, 0, NULL);
+ CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+ jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+ "tools_schema(web=0) is a valid JSON array");
+ CHECK(strstr(tb.data, "\"name\":\"web_search\"") == NULL &&
+ strstr(tb.data, "\"name\":\"web_fetch\"") == NULL,
+ "tools_schema(web=0) omits the web tools");
+ CHECK(strstr(tb.data, "\"name\":\"read\"") != NULL &&
+ strstr(tb.data, "\"name\":\"shell\"") != NULL,
+ "tools_schema(web=0) keeps the base tools");
+ json_doc_free(&jd);
+
+ /* http_request gated independently of web_search. */
+ buf_reset(&tb);
+ tools_schema(&tb, 0, 1, NULL);
+ CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+ jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+ "tools_schema(http=1) is a valid JSON array");
+ CHECK(strstr(tb.data, "\"name\":\"http_request\"") != NULL,
+ "tools_schema(http=1) includes the http_request tool");
+ CHECK(strstr(tb.data, "\"name\":\"web_search\"") == NULL,
+ "tools_schema(web=0,http=1) still omits the web tools");
+ json_doc_free(&jd);
+ buf_free(&tb);
+ }
+
+ /*
+ * Tool-call input storage must not bleed dirty trailing-capacity bytes
+ * into json_raw(). Repro for the parallel-tool overrun fixed in
+ * on_tool_call (net_api.c): the per-call input buf was filled via
+ * buf_puts_cstr() (no NUL after the bytes), and the subsequent json_raw()
+ * used strlen() to read .data -- which on a freshly xreallocarray'd
+ * buf would walk into uninitialised heap. This pre-pollutes the
+ * capacity with '!' so the bug, were it present, would emit garbage.
+ */
+ {
+ struct buf input, out;
+ struct json_doc jd2;
+ struct json_writer w;
+ const char *src = "{\"command\":\"ls -la\"}";
+ int input_t, cmd_t;
+ char *cmd;
+
+ buf_init(&input);
+ buf_reserve(&input, 64);
+ memset(input.data, '!', 64);
+ /* The pattern on_tool_call now uses: append + uncounted NUL. */
+ buf_append(&input, src, strlen(src));
+ buf_terminate(&input);
+
+ buf_init(&out);
+ json_writer_init(&w, &out);
+ json_object_start(&w);
+ json_key(&w, "input");
+ json_raw(&w, input.data);
+ json_object_end(&w);
+
+ CHECK(json_parse(&jd2, out.data, out.len) == 0 &&
+ jd2.ntok >= 1 && json_tok_type(&jd2, 0) == JSON_OBJECT,
+ "tool input survives dirty-capacity buf");
+ input_t = json_obj_get(&jd2, 0, "input");
+ cmd_t = input_t >= 0 ? json_obj_get(&jd2, input_t, "command") : -1;
+ cmd = cmd_t >= 0 ? json_tok_strdup(&jd2, cmd_t) : NULL;
+ CHECK(cmd != NULL && strcmp(cmd, "ls -la") == 0,
+ "tool input.command preserved (got '%s')",
+ cmd != NULL ? cmd : "(null)");
+ free(cmd);
+ json_doc_free(&jd2);
+ buf_free(&out);
+ buf_free(&input);
+ }
+
+ /* net_parse_models(): both providers' {"data":[{"id":...}]} shape. */
+ {
+ struct buf mb;
+
+ buf_init(&mb);
+ net_parse_models(MODELS_JSON, strlen(MODELS_JSON), &mb);
+ buf_terminate(&mb);
+ CHECK(strcmp(mb.data,
+ "model-alpha\nmodel-beta\nmodel-gamma") == 0,
+ "net_parse_models: newline-separated ids '%s'", mb.data);
+
+ buf_reset(&mb);
+ net_parse_models("{\"data\":[]}", 11, &mb);
+ CHECK(mb.len == 0, "net_parse_models: empty data -> empty");
+
+ buf_reset(&mb);
+ net_parse_models("not json", 8, &mb);
+ CHECK(mb.len == 0, "net_parse_models: malformed -> empty");
+
+ buf_reset(&mb);
+ net_parse_models("{\"error\":{\"message\":\"x\"}}", 25, &mb);
+ CHECK(mb.len == 0, "net_parse_models: no data array -> empty");
+ buf_free(&mb);
+ }
+
+ /* The full worker path: Anthropic, OpenAI at its default path, and
+ * OpenAI with an api_path override (as OpenRouter/Groq/Gemini need). */
+ run_scenario(argv[1], argv[2], NULL, NULL, ANTHROPIC_SSE, 0, NULL,
+ "anthropic");
+ run_scenario(argv[1], argv[2], "openai", NULL, OPENAI_SSE, 1,
+ "/v1/chat/completions", "openai");
+ run_scenario(argv[1], argv[2], "openai", "/api/v1/chat/completions",
+ OPENAI_SSE, 1, "/api/v1/chat/completions", "openai-pathoverride");
+
+ /* /model: fetch the provider's model list end-to-end. */
+ run_models_scenario(argv[1], argv[2]);
+
+ /* Two providers at once: the merged, per-provider-tagged model list. */
+ run_multi_models_scenario(argv[1], argv[2]);
+
+ printf("net_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 438a24d18318a85d1a70a5328df3c8b0ceb1cbae (mode 644)
--- /dev/null
+++ regress/openai/Makefile
+# Unit tests for the OpenAI provider layer (src/common/openai.c).
+
+PROG= openai_test
+SRCS= openai_test.c openai.c sse.c json.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-openai_test
+
+run-openai_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + b42e7e2ccc8004571ecc0ae1ebb3f673c5918ddf (mode 644)
--- /dev/null
+++ regress/openai/openai_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "openai.h"
+#include "sse.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* ---- callback recorder ---- */
+
+#define MAXTOOLS 8
+
+static struct buf rec_text;
+static char *rec_id[MAXTOOLS], *rec_name[MAXTOOLS], *rec_input[MAXTOOLS];
+static size_t rec_input_len[MAXTOOLS];
+static int rec_tools;
+static long long rec_in, rec_out;
+static int rec_usage;
+static char *rec_stop;
+static int rec_done;
+static char *rec_err_type, *rec_err_msg;
+static int rec_errors;
+
+static void
+rec_reset(void)
+{
+ int i;
+
+ buf_reset(&rec_text);
+ for (i = 0; i < MAXTOOLS; i++) {
+ free(rec_id[i]); rec_id[i] = NULL;
+ free(rec_name[i]); rec_name[i] = NULL;
+ free(rec_input[i]); rec_input[i] = NULL;
+ rec_input_len[i] = 0;
+ }
+ rec_tools = 0;
+ rec_in = rec_out = 0;
+ rec_usage = 0;
+ free(rec_stop); rec_stop = NULL;
+ rec_done = 0;
+ free(rec_err_type); rec_err_type = NULL;
+ free(rec_err_msg); rec_err_msg = NULL;
+ rec_errors = 0;
+}
+
+static void
+cb_text(const char *t, size_t n, void *a)
+{
+ (void)a;
+ buf_append(&rec_text, t, n);
+}
+
+static void
+cb_tool(const char *id, const char *name, const char *input, size_t n, void *a)
+{
+ (void)a;
+ if (rec_tools >= MAXTOOLS)
+ return;
+ rec_id[rec_tools] = strdup(id);
+ rec_name[rec_tools] = strdup(name);
+ rec_input[rec_tools] = malloc(n + 1);
+ memcpy(rec_input[rec_tools], input, n);
+ rec_input[rec_tools][n] = '\0';
+ rec_input_len[rec_tools] = n;
+ rec_tools++;
+}
+
+static void
+cb_usage(long long in, long long out, void *a)
+{
+ (void)a;
+ rec_in = in;
+ rec_out = out;
+ rec_usage++;
+}
+
+static void
+cb_stop(const char *sr, void *a)
+{
+ (void)a;
+ free(rec_stop);
+ rec_stop = strdup(sr);
+}
+
+static void
+cb_error(const char *type, const char *msg, void *a)
+{
+ (void)a;
+ free(rec_err_type); rec_err_type = strdup(type);
+ free(rec_err_msg); rec_err_msg = strdup(msg);
+ rec_errors++;
+}
+
+static void
+cb_done(void *a)
+{
+ (void)a;
+ rec_done++;
+}
+
+static const struct llm_cbs RECORDER = {
+ cb_text, cb_tool, cb_usage, cb_stop, cb_error, cb_done
+};
+
+static void
+ev(struct openai_stream *s, const char *data)
+{
+ openai_stream_event(s, "message", data, strlen(data));
+}
+
+/* ---- request builder (Anthropic shape -> OpenAI) ---- */
+
+static void
+check_build(const char *what, const char *model, int max_tokens,
+ const char *system, const char *messages, const char *tools,
+ const char *expect)
+{
+ struct buf out;
+
+ buf_init(&out);
+ openai_build_request(&out, model, max_tokens, system, messages, tools);
+ buf_terminate(&out);
+ CHECK(strcmp(out.data, expect) == 0, "%s:\n got: %s\nwant: %s",
+ what, out.data, expect);
+ buf_free(&out);
+}
+
+static void
+test_build_basic(void)
+{
+ check_build("build basic", "gpt-4o", 1024, NULL,
+ "[{\"role\":\"user\",\"content\":\"hello\"}]", NULL,
+ "{\"model\":\"gpt-4o\",\"max_tokens\":1024,\"stream\":true,"
+ "\"stream_options\":{\"include_usage\":true},"
+ "\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}");
+}
+
+static void
+test_build_system_tools(void)
+{
+ check_build("build sys+tools", "m", 8, "be brief",
+ "[{\"role\":\"user\",\"content\":\"hi\"}]",
+ "[{\"name\":\"read\",\"description\":\"Read\","
+ "\"input_schema\":{\"type\":\"object\"}}]",
+ "{\"model\":\"m\",\"max_tokens\":8,\"stream\":true,"
+ "\"stream_options\":{\"include_usage\":true},"
+ "\"messages\":[{\"role\":\"system\",\"content\":\"be brief\"},"
+ "{\"role\":\"user\",\"content\":\"hi\"}],"
+ "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\","
+ "\"description\":\"Read\",\"parameters\":{\"type\":\"object\"}}}]}");
+}
+
+static void
+test_build_assistant_toolcall(void)
+{
+ check_build("build assistant tool_use", "m", 4, NULL,
+ "[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+ "\"text\":\"let me check\"},{\"type\":\"tool_use\",\"id\":\"call_1\","
+ "\"name\":\"read\",\"input\":{\"path\":\"x\"}}]}]", NULL,
+ "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+ "\"stream_options\":{\"include_usage\":true},"
+ "\"messages\":[{\"role\":\"assistant\",\"content\":\"let me check\","
+ "\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\","
+ "\"function\":{\"name\":\"read\","
+ "\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]}]}");
+}
+
+static void
+test_build_tool_result(void)
+{
+ check_build("build tool_result", "m", 4, NULL,
+ "[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\","
+ "\"tool_use_id\":\"call_1\",\"content\":\"file body\","
+ "\"is_error\":false}]}]", NULL,
+ "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+ "\"stream_options\":{\"include_usage\":true},"
+ "\"messages\":[{\"role\":\"tool\",\"tool_call_id\":\"call_1\","
+ "\"content\":\"file body\"}]}");
+}
+
+static void
+test_build_empty_tools(void)
+{
+ /* An empty tool list is omitted entirely (some servers reject []). */
+ check_build("build empty tools", "m", 4, NULL,
+ "[{\"role\":\"user\",\"content\":\"hi\"}]", "[]",
+ "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+ "\"stream_options\":{\"include_usage\":true},"
+ "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}");
+}
+
+/* ---- streaming event interpreter ---- */
+
+static void
+test_text_stream(void)
+{
+ struct openai_stream s;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+ "\"content\":\"\"},\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},"
+ "\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\", world\"},"
+ "\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+ "\"finish_reason\":\"stop\"}]}");
+ ev(&s, "{\"choices\":[],\"usage\":{\"prompt_tokens\":7,"
+ "\"completion_tokens\":9}}");
+ ev(&s, "[DONE]");
+
+ buf_terminate(&rec_text);
+ CHECK(strcmp(rec_text.data, "Hello, world") == 0, "text: '%s'",
+ rec_text.data);
+ CHECK(rec_tools == 0, "text: no tool call");
+ CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "text: stop");
+ CHECK(rec_usage == 1 && rec_in == 7 && rec_out == 9,
+ "text: usage in=%lld out=%lld", rec_in, rec_out);
+ CHECK(rec_done == 1, "text: done");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_call(void)
+{
+ struct openai_stream s;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+ "\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1\","
+ "\"type\":\"function\",\"function\":{\"name\":\"read\","
+ "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"function\":{\"arguments\":\"{\\\"path\\\":\"}}]},"
+ "\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"function\":{\"arguments\":\"\\\"x\\\"}\"}}]},"
+ "\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+ "\"finish_reason\":\"tool_calls\"}]}");
+
+ CHECK(rec_tools == 1, "tool: one call (%d)", rec_tools);
+ CHECK(rec_id[0] && strcmp(rec_id[0], "call_1") == 0, "tool: id");
+ CHECK(rec_name[0] && strcmp(rec_name[0], "read") == 0, "tool: name");
+ CHECK(rec_input[0] && strcmp(rec_input[0], "{\"path\":\"x\"}") == 0,
+ "tool: input '%s'", rec_input[0] ? rec_input[0] : "(null)");
+ CHECK(rec_stop && strcmp(rec_stop, "tool_use") == 0, "tool: stop '%s'",
+ rec_stop ? rec_stop : "(null)");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_parallel_tool_calls(void)
+{
+ struct openai_stream s;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"id\":\"a\",\"function\":{\"name\":\"read\","
+ "\"arguments\":\"{}\"}},"
+ "{\"index\":1,\"id\":\"b\",\"function\":{\"name\":\"ls\","
+ "\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+ "\"finish_reason\":\"tool_calls\"}]}");
+
+ CHECK(rec_tools == 2, "parallel: two calls (%d)", rec_tools);
+ CHECK(rec_id[0] && strcmp(rec_id[0], "a") == 0 &&
+ rec_name[0] && strcmp(rec_name[0], "read") == 0, "parallel: call 0");
+ CHECK(rec_id[1] && strcmp(rec_id[1], "b") == 0 &&
+ rec_name[1] && strcmp(rec_name[1], "ls") == 0, "parallel: call 1");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_call_null_continuation(void)
+{
+ struct openai_stream s;
+
+ /*
+ * Some OpenAI-emulating servers send an explicit "id":null / "name":null
+ * on continuation deltas (only the first delta carries the real value).
+ * Those nulls must not clobber the id/name captured earlier.
+ */
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"id\":\"call_9\",\"function\":{\"name\":\"read\","
+ "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"id\":null,\"function\":{\"name\":null,"
+ "\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+ "\"finish_reason\":\"tool_calls\"}]}");
+
+ CHECK(rec_tools == 1, "null-cont: one call (%d)", rec_tools);
+ CHECK(rec_id[0] && strcmp(rec_id[0], "call_9") == 0,
+ "null-cont: id kept '%s'", rec_id[0] ? rec_id[0] : "(null)");
+ CHECK(rec_name[0] && strcmp(rec_name[0], "read") == 0,
+ "null-cont: name kept '%s'", rec_name[0] ? rec_name[0] : "(null)");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_call_embedded_nul(void)
+{
+ struct openai_stream s;
+ /*
+ * Tool-call arguments JSON containing a literal backslash-u-0000
+ * escape decodes to a byte sequence with an embedded NUL. The
+ * streaming accumulator must preserve the full length end-to-end (no
+ * strlen-based truncation at the NUL byte). Regression test for the
+ * silent truncation in accumulate_toolcalls() that swallowed
+ * everything past the first NUL.
+ */
+ static const char expect_input[] = "{\"x\":\"a\0b\"}";
+ const size_t expect_input_n = sizeof(expect_input) - 1;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"id\":\"call_n\",\"type\":\"function\","
+ "\"function\":{\"name\":\"write\","
+ "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+ /* args fragment contains a literal backslash-u-0000 escape. */
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"function\":{\"arguments\":"
+ "\"{\\\"x\\\":\\\"a\\u0000b\\\"}\"}}]},"
+ "\"finish_reason\":null}]}");
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+ "\"finish_reason\":\"tool_calls\"}]}");
+
+ CHECK(rec_tools == 1, "embedded-nul: one call (%d)", rec_tools);
+ CHECK(rec_input_len[0] == expect_input_n,
+ "embedded-nul: input length got %zu want %zu",
+ rec_input_len[0], expect_input_n);
+ CHECK(rec_input[0] != NULL &&
+ memcmp(rec_input[0], expect_input, expect_input_n) == 0,
+ "embedded-nul: input bytes mismatch");
+ CHECK(rec_id[0] && strcmp(rec_id[0], "call_n") == 0,
+ "embedded-nul: id '%s'", rec_id[0] ? rec_id[0] : "(null)");
+ CHECK(rec_name[0] && strcmp(rec_name[0], "write") == 0,
+ "embedded-nul: name '%s'", rec_name[0] ? rec_name[0] : "(null)");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_tool_call_done_without_finish(void)
+{
+ struct openai_stream s;
+
+ /* A server that jumps straight to [DONE] must still flush tool calls. */
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+ "0,\"id\":\"call_x\",\"function\":{\"name\":\"ls\","
+ "\"arguments\":\"{}\"}}]}}]}");
+ ev(&s, "[DONE]");
+
+ CHECK(rec_tools == 1 && rec_id[0] && strcmp(rec_id[0], "call_x") == 0 &&
+ rec_name[0] && strcmp(rec_name[0], "ls") == 0,
+ "done-flush: tool delivered (%d)", rec_tools);
+ CHECK(rec_done == 1, "done-flush: done fired");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_error_event(void)
+{
+ struct openai_stream s;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{\"error\":{\"message\":\"bad key\","
+ "\"type\":\"invalid_request_error\"}}");
+ CHECK(rec_errors == 1 && rec_err_type &&
+ strcmp(rec_err_type, "invalid_request_error") == 0, "error type");
+ CHECK(rec_err_msg && strcmp(rec_err_msg, "bad key") == 0, "error msg");
+ /* after a fatal error, further events are ignored */
+ ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"x\"}}]}");
+ CHECK(rec_text.len == 0, "post-error events ignored");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+static void
+test_badjson(void)
+{
+ struct openai_stream s;
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ ev(&s, "{");
+ CHECK(rec_errors == 1 && rec_err_type &&
+ strcmp(rec_err_type, "parse_error") == 0, "bad json -> parse_error");
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+/* ---- integration: SSE wire -> sse -> openai ---- */
+
+static void
+sse_trampoline(const char *event, const char *data, size_t datalen, void *arg)
+{
+ openai_stream_event(arg, event, data, datalen);
+}
+
+static void
+test_integration(void)
+{
+ struct openai_stream s;
+ struct sse_parser p;
+ const char *wire =
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":"
+ "\"assistant\",\"content\":\"\"}}]}\n"
+ "\n"
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":"
+ "\"It is \"}}]}\n"
+ "\n"
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":"
+ "\"sunny.\"}}]}\n"
+ "\n"
+ "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":"
+ "\"stop\"}]}\n"
+ "\n"
+ "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,"
+ "\"completion_tokens\":4}}\n"
+ "\n"
+ "data: [DONE]\n"
+ "\n";
+ size_t i, len = strlen(wire);
+
+ openai_stream_init(&s, &RECORDER, NULL);
+ sse_init(&p, sse_trampoline, &s);
+
+ for (i = 0; i < len; i += 7) {
+ size_t n = (len - i < 7) ? len - i : 7;
+
+ if (sse_push(&p, wire + i, n) != 0)
+ failures++;
+ }
+
+ buf_terminate(&rec_text);
+ CHECK(strcmp(rec_text.data, "It is sunny.") == 0,
+ "integration text: '%s'", rec_text.data);
+ CHECK(rec_in == 5 && rec_out == 4, "integration usage in=%lld out=%lld",
+ rec_in, rec_out);
+ CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "integration stop");
+ CHECK(rec_done == 1, "integration done");
+
+ sse_clear(&p);
+ openai_stream_clear(&s);
+ rec_reset();
+}
+
+int
+main(void)
+{
+ buf_init(&rec_text);
+
+ test_build_basic();
+ test_build_system_tools();
+ test_build_assistant_toolcall();
+ test_build_tool_result();
+ test_build_empty_tools();
+ test_text_stream();
+ test_tool_call();
+ test_parallel_tool_calls();
+ test_tool_call_null_continuation();
+ test_tool_call_embedded_nul();
+ test_tool_call_done_without_finish();
+ test_error_event();
+ test_badjson();
+ test_integration();
+
+ buf_free(&rec_text);
+ printf("openai_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 6f262c50b5dcd8f2464db1002dc2b9e9c3470f1d (mode 644)
--- /dev/null
+++ regress/parse/Makefile
+# Unit tests for the config grammar (src/common/parse.y) + resolution.
+
+PROG= parse_test
+SRCS= parse_test.c parse.y conf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-parse_test
+
+run-parse_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + ac06cdd61926629f986f47b6f445c89d6fc2d003 (mode 644)
--- /dev/null
+++ regress/parse/parse_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+
+static int fails;
+static int checks;
+
+#define CHECK(cond, msg) do { \
+ checks++; \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", \
+ __func__, __LINE__, (msg)); \
+ fails++; \
+ } \
+} while (0)
+
+static void
+streq(const char *func, int line, const char *got, const char *want,
+ const char *msg)
+{
+ checks++;
+ if (got == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s:%d: %s (got %s want %s)\n",
+ func, line, msg, got ? got : "(null)", want);
+ fails++;
+ }
+}
+#define STREQ(got, want, msg) streq(__func__, __LINE__, (got), (want), (msg))
+
+/* Write content to a fresh temp file; caller frees + unlinks the path. */
+static char *
+mktmp(const char *content)
+{
+ char *path = strdup("/tmp/fugu_parse.XXXXXX");
+ int fd;
+ size_t n = strlen(content);
+
+ if (path == NULL || (fd = mkstemp(path)) == -1) {
+ perror("mkstemp");
+ exit(2);
+ }
+ if (write(fd, content, n) != (ssize_t)n) {
+ perror("write");
+ exit(2);
+ }
+ close(fd);
+ return (path);
+}
+
+static void
+test_parse_resolve(void)
+{
+ static const char *cfg =
+ "# fugu test config\n"
+ "default_model = \"claude-sonnet-4-6\"\n"
+ "model $default_model\n"
+ "max_tokens 8192\n"
+ "allow_write no\n"
+ "search_provider \"kagi\"\n"
+ "system \"be terse\"\n"
+ "provider \"openai\"\n"
+ "api_host \"openrouter.ai\"\n"
+ "api_port \"443\"\n"
+ "api_path \"/api/v1/chat/completions\"\n"
+ "api_key \"sk-or-test\"\n"
+ "\n"
+ "match user \"isaac\" {\n"
+ "\tmodel \"claude-opus-4-8\"\n"
+ "\tallow_write yes\n"
+ "}\n"
+ "\n"
+ "match group \"interns\" {\n"
+ "\tmodel \"claude-haiku-4-5\"\n"
+ "\tweb_search no\n"
+ "}\n";
+ char *path = mktmp(cfg);
+ char *groups[] = { "interns" };
+ struct conf c;
+ struct conf_settings eff;
+
+ conf_init(&c);
+ CHECK(conf_parse_file(&c, path, 0) == 0, "parse valid config");
+
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "isaac", NULL, 0, &eff);
+ STREQ(eff.model, "claude-opus-4-8", "isaac: user block model");
+ CHECK(eff.allow_write == 1, "isaac: allow_write yes");
+ CHECK((eff.set & CS_MAX_TOKENS) && eff.max_tokens == 8192,
+ "isaac: global max_tokens");
+ STREQ(eff.search_provider, "kagi", "isaac: global search_provider");
+ STREQ(eff.system, "be terse", "isaac: global system prompt");
+ STREQ(eff.provider, "openai", "isaac: global provider");
+ STREQ(eff.api_host, "openrouter.ai", "isaac: global api_host");
+ STREQ(eff.api_port, "443", "isaac: global api_port");
+ STREQ(eff.api_path, "/api/v1/chat/completions", "isaac: global api_path");
+ STREQ(eff.api_key, "sk-or-test", "isaac: global api_key");
+ conf_settings_clear(&eff);
+
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "bob", groups, 1, &eff);
+ STREQ(eff.model, "claude-haiku-4-5", "bob: group block model");
+ CHECK((eff.set & CS_WEB_SEARCH) && eff.web_search == 0,
+ "bob: group web_search no");
+ CHECK(eff.allow_write == 0, "bob: global allow_write");
+ conf_settings_clear(&eff);
+
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "carol", NULL, 0, &eff);
+ STREQ(eff.model, "claude-sonnet-4-6", "carol: macro-expanded default");
+ CHECK(eff.allow_write == 0, "carol: global allow_write");
+ conf_settings_clear(&eff);
+
+ conf_clear(&c);
+ unlink(path);
+ free(path);
+}
+
+static void
+test_include(void)
+{
+ char *sub = mktmp("context_limit 160000\n");
+ char main_cfg[256];
+ char *path;
+ struct conf c;
+ struct conf_settings eff;
+
+ snprintf(main_cfg, sizeof(main_cfg),
+ "model \"x\"\ninclude \"%s\"\n", sub);
+ path = mktmp(main_cfg);
+
+ conf_init(&c);
+ CHECK(conf_parse_file(&c, path, 0) == 0, "parse with include");
+
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "anyone", NULL, 0, &eff);
+ STREQ(eff.model, "x", "main file setting applied");
+ CHECK((eff.set & CS_CONTEXT_LIMIT) && eff.context_limit == 160000,
+ "included file setting applied");
+ conf_settings_clear(&eff);
+
+ conf_clear(&c);
+ unlink(path);
+ unlink(sub);
+ free(path);
+ free(sub);
+}
+
+static void
+test_malformed(void)
+{
+ char *path = mktmp("this is not valid\n");
+ struct conf c;
+
+ conf_init(&c);
+ CHECK(conf_parse_file(&c, path, 0) == -1, "malformed config rejected");
+ conf_clear(&c);
+ unlink(path);
+ free(path);
+}
+
+/*
+ * Named provider blocks parse into c.providers, in order, alongside the flat
+ * "default" keys -- and the flat `provider "openai"` setting (no brace) still
+ * works, proving the block syntax does not shadow it.
+ */
+static void
+test_provider_blocks(void)
+{
+ static const char *cfg =
+ "provider \"anthropic\"\n" /* flat setting: default's type */
+ "api_key \"sk-ant-default\"\n"
+ "\n"
+ "provider \"gpt\" {\n"
+ "\ttype \"openai\"\n"
+ "\tapi_key \"sk-openai\"\n"
+ "\tmodel \"gpt-5\"\n"
+ "}\n"
+ "\n"
+ "provider \"groq\" {\n"
+ "\ttype \"openai\"\n"
+ "\tapi_host \"api.groq.com\"\n"
+ "\tapi_path \"/openai/v1/chat/completions\"\n"
+ "}\n";
+ char *path = mktmp(cfg);
+ struct conf c;
+ struct conf_settings eff;
+ struct conf_provider *p;
+ int n = 0;
+
+ conf_init(&c);
+ CHECK(conf_parse_file(&c, path, 0) == 0, "parse config with provider blocks");
+
+ /* The flat default provider's type/key resolve normally. */
+ memset(&eff, 0, sizeof(eff));
+ conf_resolve(&c, "anyone", NULL, 0, &eff);
+ STREQ(eff.provider, "anthropic", "default: flat provider setting");
+ STREQ(eff.api_key, "sk-ant-default", "default: flat api_key");
+ conf_settings_clear(&eff);
+
+ TAILQ_FOREACH(p, &c.providers, entry)
+ n++;
+ CHECK(n == 2, "two named provider blocks parsed");
+
+ p = TAILQ_FIRST(&c.providers);
+ STREQ(p->name, "gpt", "first block name");
+ STREQ(p->settings.provider, "openai", "first block: type -> provider field");
+ STREQ(p->settings.api_key, "sk-openai", "first block api_key");
+ STREQ(p->settings.model, "gpt-5", "first block model");
+
+ p = TAILQ_NEXT(p, entry);
+ STREQ(p->name, "groq", "second block name");
+ STREQ(p->settings.api_host, "api.groq.com", "second block api_host");
+ STREQ(p->settings.api_path, "/openai/v1/chat/completions",
+ "second block api_path");
+ CHECK((p->settings.set & CS_API_KEY) == 0, "second block has no key");
+
+ conf_clear(&c);
+ unlink(path);
+ free(path);
+}
+
+/*
+ * Empty-string macros must lex without undefined behavior: the macro-expansion
+ * path in parse.c used to compute `val + strlen(val) - 1` unconditionally,
+ * which is UB (forms a pointer one before the start of the array) when the
+ * macro value is empty. Reaching the expansion site at all is enough to
+ * exercise the fix; the surrounding directive may or may not parse cleanly,
+ * but the lexer must not invoke UB on the way through.
+ */
+static void
+test_empty_macro(void)
+{
+ static const char *cfg =
+ "empty = \"\"\n"
+ "model $empty\n";
+ char *path = mktmp(cfg);
+ struct conf c;
+
+ conf_init(&c);
+ /*
+ * We don't assert the return value: an empty expansion may leave the
+ * `model` directive without a string, which is fine to reject. The
+ * point is that we get here without tripping UBSan / crashing.
+ */
+ (void)conf_parse_file(&c, path, 0);
+ CHECK(1, "empty-macro expansion did not crash");
+ conf_clear(&c);
+ unlink(path);
+ free(path);
+}
+
+int
+main(void)
+{
+ test_parse_resolve();
+ test_include();
+ test_malformed();
+ test_provider_blocks();
+ test_empty_macro();
+
+ printf("%d checks, %d failures\n", checks, fails);
+ return (fails != 0);
+}
blob - /dev/null
blob + 47456b2e14f3144166a860ac61f60a5cdcac086f (mode 644)
--- /dev/null
+++ regress/resume/Makefile
+# Unit tests for the session resume path (src/ui/convo.c + journal.c) plus a
+# `fugu -l` listing smoke test. The smoke test runs the parent binary, so run
+# `make` at the top level first; the unit tests need no built binaries.
+
+PROG= resume_test
+SRCS= resume_test.c convo.c journal.c json.c buf.c util.c log.c
+NOMAN= yes
+
+UIDIR= ${.CURDIR}/../../src/ui
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${UIDIR} -I${COMMONDIR}
+.PATH: ${UIDIR} ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+FUGU_BIN= ${.CURDIR}/../../src/parent/fugu
+
+REGRESS_TARGETS= run-resume_test
+
+run-resume_test: ${PROG}
+ FUGU_BIN=${FUGU_BIN} ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + cf300812821ebdfa3bc3852a79490ab3c58b1c79 (mode 644)
--- /dev/null
+++ regress/resume/resume_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the session resume path (src/ui/convo.c + journal.c):
+ * - convo_replay decomposes stored message objects into display parts;
+ * - the journal write path and journal_foreach round-trip exactly;
+ * - convo_resume rebuilds a conversation and replays it for display;
+ * - a resumed journal keeps appending to the same file.
+ * Plus a smoke test of `fugu -l` if FUGU_BIN is set (the parent's listing path).
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <dirent.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "journal.h"
+#include "ui.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* Records the parts convo_replay hands back, for assertions. */
+struct rec {
+ enum convo_part part[32];
+ char *text[32];
+ int n;
+};
+
+static void
+rec_cb(void *ctx, enum convo_part p, const char *text, size_t len)
+{
+ struct rec *r = ctx;
+
+ if (r->n < 32) {
+ r->part[r->n] = p;
+ r->text[r->n] = strndup(text, len);
+ r->n++;
+ }
+}
+
+static void
+rec_free(struct rec *r)
+{
+ int i;
+
+ for (i = 0; i < r->n; i++)
+ free(r->text[i]);
+ r->n = 0;
+}
+
+static void
+test_replay(void)
+{
+ struct rec r;
+ const char *m;
+
+ /* A typed user message is one CP_USER part. */
+ memset(&r, 0, sizeof(r));
+ m = "{\"role\":\"user\",\"content\":\"hello world\"}";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 1 && r.part[0] == CP_USER &&
+ strcmp(r.text[0], "hello world") == 0, "user string -> CP_USER");
+ rec_free(&r);
+
+ /* Assistant text block -> one CP_ASSIST part. */
+ memset(&r, 0, sizeof(r));
+ m = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"hi there\"}]}";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 1 && r.part[0] == CP_ASSIST &&
+ strcmp(r.text[0], "hi there") == 0, "assistant text -> CP_ASSIST");
+ rec_free(&r);
+
+ /* tool_use -> one CP_TOOL part formatted "name input". */
+ memset(&r, 0, sizeof(r));
+ m = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\","
+ "\"input\":{\"path\":\"foo\"}}]}";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 1 && r.part[0] == CP_TOOL &&
+ strcmp(r.text[0], "read {\"path\":\"foo\"}") == 0,
+ "tool_use -> CP_TOOL (got %s)", r.n ? r.text[0] : "(none)");
+ rec_free(&r);
+
+ /* tool_result (synth-pair skill body) -> one CP_TOOL part. */
+ memset(&r, 0, sizeof(r));
+ m = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"t1\","
+ "\"content\":\"file data\"}]}";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 1 && r.part[0] == CP_TOOL &&
+ strcmp(r.text[0], "file data") == 0,
+ "tool_result -> CP_TOOL (got %d parts)", r.n);
+ rec_free(&r);
+
+ /* Mixed assistant text + tool_use -> two parts in order. */
+ memset(&r, 0, sizeof(r));
+ m = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"let me look\"},"
+ "{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"ls\","
+ "\"input\":{}}]}";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 2 && r.part[0] == CP_ASSIST && r.part[1] == CP_TOOL,
+ "mixed assistant -> text then tool (got %d)", r.n);
+ if (r.n == 2)
+ CHECK(strcmp(r.text[1], "ls {}") == 0, "tool with empty input");
+ rec_free(&r);
+
+ /* Malformed input must not crash and yields nothing. */
+ memset(&r, 0, sizeof(r));
+ m = "{not json";
+ convo_replay(m, strlen(m), rec_cb, &r);
+ CHECK(r.n == 0, "malformed message -> no parts");
+ rec_free(&r);
+}
+
+static void
+test_journal_roundtrip(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct rec r;
+ struct buf proj;
+ char id[64];
+ const char *l0 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *l1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *l2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+
+ /* Write a fresh session. */
+ journal_open(&j, NULL);
+ CHECK(j.fd >= 0, "journal_open mints and opens a session");
+ if (j.fd < 0)
+ return;
+ strlcpy(id, j.id, sizeof(id));
+ journal_append(&j, l0, strlen(l0));
+ journal_append(&j, l1, strlen(l1));
+ journal_append(&j, l2, strlen(l2));
+ journal_close(&j);
+
+ /* Resume it: convo is rebuilt and replayed. */
+ memset(&cv, 0, sizeof(cv));
+ memset(&r, 0, sizeof(r));
+ CHECK(convo_resume(&cv, id, rec_cb, &r) == 0, "convo_resume reads back");
+ CHECK(cv.n == 3, "all three messages restored (got %zu)", cv.n);
+
+ /* Replay drew: user q1, assistant a1, user q2. */
+ CHECK(r.n == 3, "three display parts (got %d)", r.n);
+ if (r.n == 3) {
+ CHECK(r.part[0] == CP_USER && strcmp(r.text[0], "q1") == 0,
+ "part 0 user q1");
+ CHECK(r.part[1] == CP_ASSIST && strcmp(r.text[1], "a1") == 0,
+ "part 1 assistant a1");
+ CHECK(r.part[2] == CP_USER && strcmp(r.text[2], "q2") == 0,
+ "part 2 user q2");
+ }
+
+ /* The rebuilt projection is the three raw lines as a JSON array. */
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ {
+ struct buf want;
+
+ buf_init(&want);
+ buf_printf(&want, "[%s,%s,%s]", l0, l1, l2);
+ buf_putc(&want, '\0');
+ CHECK(strcmp(proj.data, want.data) == 0,
+ "projection matches the journal");
+ buf_free(&want);
+ }
+ buf_free(&proj);
+ rec_free(&r);
+ convo_free(&cv);
+
+ /* Reopening the same id appends to the same file (continuity). */
+ journal_open(&j, id);
+ CHECK(j.fd >= 0 && strcmp(j.id, id) == 0, "reopen keeps the id");
+ journal_append(&j, l2, strlen(l2));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+ CHECK(cv.n == 4, "appended message extends the file (got %zu)", cv.n);
+ convo_free(&cv);
+}
+
+static void
+test_markers(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct rec r;
+ struct buf mk, proj;
+ const char *id = "markers-1"; /* explicit id: no mint collision */
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+ const char *u3 = "{\"role\":\"user\",\"content\":\"q3\"}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for markers");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ buf_init(&mk); /* a /clear marker */
+ convo_marker_clear(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u2, strlen(u2));
+ buf_init(&mk); /* a /compact marker (summary "SUM") */
+ convo_marker_compact(&mk, "SUM");
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u3, strlen(u3));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ memset(&r, 0, sizeof(r));
+ convo_resume(&cv, id, rec_cb, &r);
+
+ /* Active context = the summary pair (user frame + assistant SUM) + q3. */
+ CHECK(cv.n == 3, "markers: active context is summary pair + q3 (got %zu)",
+ cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "SUM") != NULL, "summary kept in projection");
+ CHECK(strstr(proj.data, "q3") != NULL, "post-compact msg in projection");
+ CHECK(strstr(proj.data, "q1") == NULL, "pre-clear msg dropped");
+ CHECK(strstr(proj.data, "q2") == NULL, "pre-compact msg dropped");
+ buf_free(&proj);
+
+ /* Display replays everything with dividers: 7 parts in order. */
+ CHECK(r.n == 7, "markers: 7 display parts (got %d)", r.n);
+ if (r.n == 7) {
+ CHECK(r.part[2] == CP_INFO, "clear shown as a divider");
+ CHECK(r.part[4] == CP_INFO && r.part[5] == CP_ASSIST,
+ "compact shown as divider + summary");
+ CHECK(r.part[6] == CP_USER && strcmp(r.text[6], "q3") == 0,
+ "post-compact message shown");
+ }
+ rec_free(&r);
+ convo_free(&cv);
+}
+
+/*
+ * A Phase-2 synth pair: a skill marker, an assistant tool_use, and a user
+ * tool_result whose `content` is the skill body. Resume must surface the body
+ * as a CP_TOOL part so the rebuilt transcript shows what the user saw live.
+ */
+static void
+test_skill_synth_pair(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct rec r;
+ const char *id = "synth-1";
+ const char *mk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+ const char *au = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"tu1\","
+ "\"name\":\"hello\",\"input\":{}}]}";
+ const char *ur = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+ "\"content\":\"HELLO BODY\"}]}";
+ int i, saw_body = 0;
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for synth pair");
+ return;
+ }
+ journal_append(&j, mk, strlen(mk));
+ journal_append(&j, au, strlen(au));
+ journal_append(&j, ur, strlen(ur));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ memset(&r, 0, sizeof(r));
+ convo_resume(&cv, id, rec_cb, &r);
+
+ /* Walk the replayed parts; the tool_result body must be surfaced. */
+ for (i = 0; i < r.n; i++)
+ if (r.part[i] == CP_TOOL &&
+ strcmp(r.text[i], "HELLO BODY") == 0)
+ saw_body = 1;
+ CHECK(saw_body, "synth pair: tool_result body surfaced as CP_TOOL");
+ rec_free(&r);
+ convo_free(&cv);
+}
+
+/*
+ * A turn that failed mid-flight wrote three records and then a `turn_failed`
+ * marker. Resume must roll the convo back to the boundary that opened the
+ * failed turn (the skill marker), drop the orphan trio, and emit a CP_INFO
+ * divider so the user sees what happened. A fresh user message that follows
+ * is preserved.
+ */
+static void
+test_turn_failed_orphan_trio(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct rec r;
+ struct buf mk, proj;
+ const char *id = "tf-orphan-1";
+ const char *sk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+ const char *up = "{\"role\":\"user\",\"content\":\"/hello\"}";
+ const char *am = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"tu1\","
+ "\"name\":\"hello\",\"input\":{}}]}";
+ const char *um = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+ "\"content\":\"HELLO BODY\"}]}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"q after\"}";
+ int i, saw_info = 0;
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for turn-failed orphan");
+ return;
+ }
+ journal_append(&j, sk, strlen(sk));
+ journal_append(&j, up, strlen(up));
+ journal_append(&j, am, strlen(am));
+ journal_append(&j, um, strlen(um));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u2, strlen(u2));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ memset(&r, 0, sizeof(r));
+ convo_resume(&cv, id, rec_cb, &r);
+
+ CHECK(cv.n == 1, "orphan trio dropped, only fresh user kept (got %zu)",
+ cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "q after") != NULL, "fresh user preserved");
+ CHECK(strstr(proj.data, "HELLO BODY") == NULL,
+ "orphan tool_result body dropped");
+ CHECK(strstr(proj.data, "tool_use") == NULL,
+ "orphan tool_use dropped");
+ buf_free(&proj);
+
+ for (i = 0; i < r.n; i++)
+ if (r.part[i] == CP_INFO &&
+ strcmp(r.text[i], "previous failed turn discarded") == 0)
+ saw_info = 1;
+ CHECK(saw_info, "CP_INFO 'previous failed turn discarded' emitted");
+ rec_free(&r);
+ convo_free(&cv);
+}
+
+/*
+ * A plain-user turn (no skill marker) that fails leaves a typed-user message
+ * + partial assistant text in the journal followed by a turn_failed marker.
+ * Resume rolls back to the typed-user boundary -- dropping both -- and keeps
+ * earlier conversation intact.
+ */
+static void
+test_turn_failed_plain_user(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk, proj;
+ const char *id = "tf-plain-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+ const char *a2p = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"partial\"}]}";
+ const char *u3 = "{\"role\":\"user\",\"content\":\"q3 after\"}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for plain-user turn failed");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ journal_append(&j, u2, strlen(u2));
+ journal_append(&j, a2p, strlen(a2p));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u3, strlen(u3));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 3, "plain failed turn rolled back to q1+a1+q3 (got %zu)",
+ cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "q1") != NULL, "q1 preserved");
+ CHECK(strstr(proj.data, "a1") != NULL, "a1 preserved");
+ CHECK(strstr(proj.data, "q3 after") != NULL, "post-failure q3 preserved");
+ CHECK(strstr(proj.data, "q2") == NULL, "failed-turn q2 dropped");
+ CHECK(strstr(proj.data, "partial") == NULL,
+ "partial assistant dropped");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+/*
+ * Two failed turns in a row. Each turn_failed rolls back to its own boundary;
+ * we should be left with the unrelated context before either turn started.
+ */
+static void
+test_turn_failed_back_to_back(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk, proj;
+ const char *id = "tf-double-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+ const char *u3 = "{\"role\":\"user\",\"content\":\"q3\"}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for back-to-back turn failed");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ journal_append(&j, u2, strlen(u2));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u3, strlen(u3));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 2, "back-to-back failures leave q1+a1 (got %zu)", cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "q1") != NULL, "q1 preserved");
+ CHECK(strstr(proj.data, "a1") != NULL, "a1 preserved");
+ CHECK(strstr(proj.data, "q2") == NULL, "first failed turn dropped");
+ CHECK(strstr(proj.data, "q3") == NULL, "second failed turn dropped");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+/*
+ * Two turn_failed markers with NO intervening message: the second one runs
+ * against the already-truncated state and must be a clean no-op (truncate to
+ * the same boundary). Pins down that a queued/retried error event can't roll
+ * back further than the first one already did.
+ */
+static void
+test_turn_failed_adjacent_markers(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk, proj;
+ const char *id = "tf-adj-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for adjacent markers");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ journal_append(&j, u2, strlen(u2));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ journal_append(&j, mk.data, mk.len); /* second, adjacent */
+ buf_free(&mk);
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 2, "adjacent markers leave q1+a1 (got %zu)", cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "q1") != NULL && strstr(proj.data, "a1") != NULL,
+ "q1+a1 survive the doubled marker");
+ CHECK(strstr(proj.data, "q2") == NULL,
+ "second marker did not roll back past the first");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+/*
+ * A bare turn_failed marker with no prior content (journal starts with it) is
+ * a no-op: the boundary is 0, the convo is already empty, and we still emit
+ * the CP_INFO divider. This must not crash or leave malformed state, and the
+ * subsequent typed-user message must reach the rebuilt convo intact.
+ */
+static void
+test_turn_failed_at_start(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct rec r;
+ struct buf mk, proj;
+ const char *id = "tf-start-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"hello\"}";
+ int i, info_count = 0;
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for turn-failed at start");
+ return;
+ }
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u1, strlen(u1));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ memset(&r, 0, sizeof(r));
+ convo_resume(&cv, id, rec_cb, &r);
+
+ CHECK(cv.n == 1, "turn_failed at journal start is a no-op (got %zu)",
+ cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "hello") != NULL,
+ "post-marker user message survives intact");
+ buf_free(&proj);
+ for (i = 0; i < r.n; i++)
+ if (r.part[i] == CP_INFO &&
+ strcmp(r.text[i], "previous failed turn discarded") == 0)
+ info_count++;
+ CHECK(info_count == 1, "exactly one 'discarded' CP_INFO (got %d)",
+ info_count);
+ rec_free(&r);
+ convo_free(&cv);
+}
+
+/*
+ * A failed turn AFTER a /compact must roll back ONLY the failed turn, leaving
+ * the compact's summary pair intact. Pins down that RK_COMPACT sets boundary
+ * to cv->n (= 2 post-summary), so a subsequent turn_failed truncates to 2 and
+ * preserves the synthetic user/assistant pair carrying the summary.
+ */
+static void
+test_turn_failed_after_compact(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk, proj;
+ const char *id = "tf-compact-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"early\"}";
+ const char *u2 = "{\"role\":\"user\",\"content\":\"after\"}";
+ const char *a2p = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"partial\"}]}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for after-compact");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ buf_init(&mk);
+ convo_marker_compact(&mk, "SUMMARY");
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, u2, strlen(u2));
+ journal_append(&j, a2p, strlen(a2p));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 2, "compact summary pair survives the failure (got %zu)",
+ cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "SUMMARY") != NULL,
+ "summary text preserved across the failure");
+ CHECK(strstr(proj.data, "after") == NULL,
+ "failed-turn user dropped");
+ CHECK(strstr(proj.data, "partial") == NULL,
+ "failed-turn partial assistant dropped");
+ CHECK(strstr(proj.data, "early") == NULL,
+ "pre-compact content gone (compact already cleared it)");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+/*
+ * A failed turn AFTER a /clear must roll back to the cleared state -- the
+ * convo stays empty. Pins down that RK_CLEAR resets boundary to 0 so a
+ * subsequent turn_failed can't reintroduce pre-clear content.
+ */
+static void
+test_turn_failed_after_clear(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk;
+ const char *id = "tf-clear-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *a2p = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"partial\"}]}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for after-clear");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ buf_init(&mk);
+ convo_marker_clear(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, a2p, strlen(a2p));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 0,
+ "post-clear failure leaves cleared state (got %zu)", cv.n);
+ convo_free(&cv);
+}
+
+/*
+ * Recovery: a skill turn failed, then the user retried with another skill
+ * turn that succeeded. Only the first trio is gone; the second replays
+ * intact. The canonical real-world resume shape after a transient error.
+ */
+static void
+test_recovery_after_failure(void)
+{
+ struct journal j;
+ struct convo cv;
+ struct buf mk, proj;
+ const char *id = "tf-recovery-1";
+ const char *sk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+ const char *up = "{\"role\":\"user\",\"content\":\"/hello\"}";
+ const char *am = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"tu1\","
+ "\"name\":\"hello\",\"input\":{}}]}";
+ const char *um = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+ "\"content\":\"BODY1\"}]}";
+ const char *sk2 = "{\"fugu\":\"skill\",\"name\":\"/world\"}";
+ const char *up2 = "{\"role\":\"user\",\"content\":\"/world\"}";
+ const char *am2 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"tu2\","
+ "\"name\":\"world\",\"input\":{}}]}";
+ const char *um2 = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"tu2\","
+ "\"content\":\"BODY2\"}]}";
+ const char *a2 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"ok\"}]}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for recovery");
+ return;
+ }
+ journal_append(&j, sk, strlen(sk));
+ journal_append(&j, up, strlen(up));
+ journal_append(&j, am, strlen(am));
+ journal_append(&j, um, strlen(um));
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(&j, mk.data, mk.len);
+ buf_free(&mk);
+ journal_append(&j, sk2, strlen(sk2));
+ journal_append(&j, up2, strlen(up2));
+ journal_append(&j, am2, strlen(am2));
+ journal_append(&j, um2, strlen(um2));
+ journal_append(&j, a2, strlen(a2));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ convo_resume(&cv, id, NULL, NULL);
+
+ CHECK(cv.n == 4, "second trio + assistant survive (got %zu)", cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "BODY2") != NULL,
+ "second skill body preserved");
+ CHECK(strstr(proj.data, "BODY1") == NULL,
+ "first (failed) skill body dropped");
+ CHECK(strstr(proj.data, "tu2") != NULL,
+ "second tool_use survives");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+/*
+ * Old-format journal (no turn_failed marker) containing the actual orphan
+ * shape -- a skill marker + tool_use + tool_result with no terminating
+ * turn_failed -- must still replay without crashing. We don't auto-clean
+ * retroactively (the buggy convo is what we get), but no new code may regress
+ * the no-crash guarantee or start dropping the orphan synth trio silently.
+ */
+static void
+test_legacy_journal_no_marker(void)
+{
+ struct convo cv;
+ struct buf proj;
+ struct journal j;
+ const char *id = "tf-legacy-1";
+ const char *u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+ const char *a1 = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"text\",\"text\":\"a1\"}]}";
+ const char *sk = "{\"fugu\":\"skill\",\"name\":\"/legacy\"}";
+ const char *am = "{\"role\":\"assistant\",\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"L1\","
+ "\"name\":\"legacy\",\"input\":{}}]}";
+ const char *um = "{\"role\":\"user\",\"content\":["
+ "{\"type\":\"tool_result\",\"tool_use_id\":\"L1\","
+ "\"content\":\"LEGACY BODY\"}]}";
+
+ journal_open(&j, id);
+ if (j.fd < 0) {
+ CHECK(0, "journal_open for legacy");
+ return;
+ }
+ journal_append(&j, u1, strlen(u1));
+ journal_append(&j, a1, strlen(a1));
+ journal_append(&j, sk, strlen(sk));
+ journal_append(&j, am, strlen(am));
+ journal_append(&j, um, strlen(um));
+ journal_close(&j);
+
+ memset(&cv, 0, sizeof(cv));
+ CHECK(convo_resume(&cv, id, NULL, NULL) == 0,
+ "legacy journal still replays");
+ CHECK(cv.n == 4,
+ "legacy orphan synth trio kept (no auto-clean) (got %zu)", cv.n);
+ buf_init(&proj);
+ convo_build(&cv, &proj);
+ buf_putc(&proj, '\0');
+ CHECK(strstr(proj.data, "LEGACY BODY") != NULL,
+ "legacy orphan tool_result body preserved");
+ CHECK(strstr(proj.data, "tool_use") != NULL,
+ "legacy orphan tool_use preserved");
+ buf_free(&proj);
+ convo_free(&cv);
+}
+
+static void
+test_journal_errors(void)
+{
+ struct convo cv;
+
+ memset(&cv, 0, sizeof(cv));
+ CHECK(convo_resume(&cv, "does-not-exist", NULL, NULL) == -1,
+ "missing session -> -1");
+ CHECK(convo_resume(&cv, "../escape", NULL, NULL) == -1,
+ "path-traversal id rejected");
+ CHECK(convo_resume(&cv, "a/b", NULL, NULL) == -1, "slash id rejected");
+ CHECK(cv.n == 0, "failed resume left convo empty");
+ convo_free(&cv);
+}
+
+/* Smoke test of `fugu -l`: write a session, run the parent, check the listing. */
+static void
+test_list_smoke(const char *home)
+{
+ char path[1024], out[4096];
+ const char *fugu_bin;
+ FILE *fp;
+ pid_t pid;
+ ssize_t k;
+ size_t outlen = 0;
+ int fd, pfd[2], status;
+
+ if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+ return; /* unit-only run: skip */
+
+ /* A minimal config so the parent's conf_load is satisfied. */
+ snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+ if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+ return;
+ (void)fchmod(fd, 0600);
+ if ((fp = fdopen(fd, "w")) != NULL) {
+ fputs("anthropic_key \"test-key\"\n", fp);
+ fclose(fp);
+ } else
+ close(fd);
+
+ /* A session file with a known id and first message. */
+ snprintf(path, sizeof(path), "%s/.fugu/sessions/9999999999-1.jsonl",
+ home);
+ if ((fp = fopen(path, "w")) == NULL)
+ return;
+ fputs("{\"role\":\"user\",\"content\":\"hello from resume test\"}\n", fp);
+ fputs("{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+ "\"text\":\"hi\"}]}\n", fp);
+ fclose(fp);
+
+ if (pipe(pfd) == -1)
+ return;
+ if ((pid = fork()) == -1) {
+ close(pfd[0]);
+ close(pfd[1]);
+ return;
+ }
+ if (pid == 0) {
+ dup2(pfd[1], STDOUT_FILENO);
+ close(pfd[0]);
+ close(pfd[1]);
+ execl(fugu_bin, fugu_bin, "-l", (char *)NULL);
+ _exit(127);
+ }
+ close(pfd[1]);
+ while (outlen < sizeof(out) - 1) {
+ k = read(pfd[0], out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ }
+ out[outlen] = '\0';
+ close(pfd[0]);
+ waitpid(pid, &status, 0);
+
+ CHECK(strstr(out, "9999999999-1") != NULL,
+ "fugu -l lists the session id");
+ CHECK(strstr(out, "hello from resume test") != NULL,
+ "fugu -l shows the first-message preview");
+}
+
+int
+main(void)
+{
+ char home[] = "/tmp/fugu-resume.XXXXXX";
+ char path[1024];
+
+ if (mkdtemp(home) == NULL) {
+ perror("mkdtemp");
+ return (1);
+ }
+ setenv("HOME", home, 1);
+ /* Not setgid in the harness, so the parent honours FUGU_CONF. */
+ snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+ setenv("FUGU_CONF", path, 1);
+ snprintf(path, sizeof(path), "%s/.fugu", home);
+ mkdir(path, 0700);
+ snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+ mkdir(path, 0700);
+
+ test_replay();
+ test_journal_roundtrip();
+ test_markers();
+ test_skill_synth_pair();
+ test_turn_failed_orphan_trio();
+ test_turn_failed_plain_user();
+ test_turn_failed_back_to_back();
+ test_turn_failed_adjacent_markers();
+ test_turn_failed_at_start();
+ test_turn_failed_after_compact();
+ test_turn_failed_after_clear();
+ test_recovery_after_failure();
+ test_legacy_journal_no_marker();
+ test_journal_errors();
+ test_list_smoke(home);
+
+ /* Best-effort cleanup of the temp HOME tree. */
+ {
+ char p[1100];
+ DIR *d;
+ struct dirent *e;
+
+ snprintf(p, sizeof(p), "%s/.fugu/sessions", home);
+ if ((d = opendir(p)) != NULL) {
+ while ((e = readdir(d)) != NULL) {
+ char f[2200];
+
+ if (e->d_name[0] == '.')
+ continue;
+ snprintf(f, sizeof(f), "%s/%s", p, e->d_name);
+ unlink(f);
+ }
+ closedir(d);
+ }
+ rmdir(p);
+ snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+ unlink(p);
+ snprintf(p, sizeof(p), "%s/.fugu", home);
+ rmdir(p);
+ rmdir(home);
+ }
+
+ printf("resume_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + effefbadac2b39672752754e3dca229d60666d13 (mode 644)
--- /dev/null
+++ regress/sandbox/Makefile
+# Sandbox verification for the exec worker: the pledge/unveil boundary holds
+# (no network for tool subprocesses by default, no writes outside the project
+# dir), driving the real exec_sandbox()/exec_loop().
+
+PROG= sandbox_test
+SRCS= sandbox_test.c exec_run.c tools_file.c tools_shell.c \
+ json.c buf.c util.c log.c imsgproto.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+EXECDIR= ${.CURDIR}/../../src/exec
+CFLAGS+= -I${COMMONDIR} -I${EXECDIR}
+.PATH: ${COMMONDIR} ${EXECDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-sandbox_test
+
+run-sandbox_test: ${PROG}
+ ./${PROG}
+
+# Syscall-level view (run manually): trace the worker and its children, then
+# show the denied network syscalls that enforce the no-network boundary.
+ktrace: ${PROG}
+ @rm -f ktrace.out
+ @ktrace -di -f ktrace.out ./${PROG} >/dev/null 2>&1 || true
+ @echo "--- denied syscalls in the sandboxed children ---"
+ @kdump -f ktrace.out | \
+ grep -iE "pledge|Operation not permitted" | head
+ @rm -f ktrace.out
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 64a161c1377c83da3e0f36be75926f1d7cb1f3d7 (mode 644)
--- /dev/null
+++ regress/sandbox/sandbox_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Sandbox verification for the exec worker. Drives the real exec_sandbox() +
+ * exec_loop() and asserts the security boundary holds:
+ *
+ * - with the default policy, a network-using base tool (ftp) run by the shell
+ * tool is denied: ftp inherits an execpromises ceiling without "inet", so
+ * its own pledge(2) fails ("pledge: Operation not permitted");
+ * - a write outside the unveil'd project directory is refused;
+ * - ordinary commands still run (the sandbox is confining, not broken);
+ * - with allow_subprocess_net, the same ftp is NOT pledge-denied (the network
+ * is permitted at the pledge level; the connection itself may still fail).
+ *
+ * This exercises the same code path fugu-exec uses; for a syscall-level view,
+ * run it under ktrace(1) and inspect with kdump(1) (see the Makefile).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/* The shell command used to probe the network: ftp pledges "inet" itself. */
+#define FTP_PROBE \
+ "{\"command\":\"ftp -o /dev/null http://127.0.0.1:9/ 2>&1; true\"}"
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+ int r, n;
+
+ for (;;) {
+ if ((r = ip_get(ip, m)) == 1)
+ return (1);
+ if (r == -1)
+ return (-1);
+ if ((n = ip_read(ip)) <= 0)
+ return (0);
+ }
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+ const char *input_json)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_kv_string(&w, "name", name);
+ json_key(&w, "input");
+ json_raw(&w, input_json);
+ json_object_end(&w);
+ if (ip_compose(ip, M_TOOL_REQUEST, EP_EXEC, EP_NET, b.data,
+ b.len) == -1 || ip_flush(ip) == -1)
+ errx(1, "send_req");
+ buf_free(&b);
+}
+
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+ struct fugu_msg m;
+ struct json_doc d;
+ char *c;
+ int t;
+
+ if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+ return (-1);
+ if (json_parse(&d, m.data, m.len) == -1) {
+ ip_msg_free(&m);
+ return (-1);
+ }
+ content[0] = '\0';
+ if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+ (c = json_tok_strdup(&d, t)) != NULL) {
+ strlcpy(content, c, cap);
+ free(c);
+ }
+ t = json_obj_get(&d, 0, "is_error");
+ *is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+ json_doc_free(&d);
+ ip_msg_free(&m);
+ return (0);
+}
+
+/* Fork a sandboxed exec worker chdir'd to cwd; return its pid and parent imsg. */
+static pid_t
+spawn_worker(int allow_net, const char *cwd, struct imsgproto *ip)
+{
+ pid_t pid;
+ int sv[2];
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ err(1, "socketpair");
+ if ((pid = fork()) == -1)
+ err(1, "fork");
+ if (pid == 0) {
+ struct imsgproto eip;
+
+ close(sv[0]);
+ if (chdir(cwd) == -1)
+ _exit(40);
+ if (ip_init(&eip, sv[1]) == -1)
+ _exit(41);
+ exec_sandbox(allow_net);
+ exec_loop(&eip);
+ _exit(0);
+ }
+ close(sv[1]);
+ if (ip_init(ip, sv[0]) == -1)
+ err(1, "ip_init");
+ return (pid);
+}
+
+static void
+stop_worker(struct imsgproto *ip, pid_t pid)
+{
+ int status;
+
+ if (ip_compose(ip, M_SHUTDOWN, EP_EXEC, EP_PARENT, NULL, 0) != -1)
+ ip_flush(ip);
+ if (waitpid(pid, &status, 0) == -1)
+ err(1, "waitpid");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "exec worker exit %d", status);
+ ip_clear(ip);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "sandbox_test: TIMED OUT\n");
+ _exit(97);
+}
+
+int
+main(void)
+{
+ struct imsgproto ip;
+ char content[8192], td[] = "/tmp/fugu-sbx.XXXXXX";
+ const char *escape = "/tmp/fugu-sbx-escape.txt";
+ pid_t pid;
+ int is_err;
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(60);
+
+ if (mkdtemp(td) == NULL)
+ err(1, "mkdtemp");
+ unlink(escape);
+
+ /* ---- default policy: no network for the worker or its children ---- */
+ pid = spawn_worker(0, td, &ip);
+
+ send_req(&ip, "n1", "shell", FTP_PROBE);
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(strstr(content, "pledge") != NULL,
+ "no-net: a network command is denied by pledge (%s)", content);
+
+ send_req(&ip, "n2", "write",
+ "{\"path\":\"/tmp/fugu-sbx-escape.txt\",\"content\":\"x\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(is_err, "no-net: write outside the project dir is refused");
+ CHECK(access(escape, F_OK) == -1, "no-net: escape file not created");
+
+ send_req(&ip, "n3", "shell", "{\"command\":\"echo ok-sandbox\"}");
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(!is_err && strstr(content, "ok-sandbox") != NULL,
+ "no-net: ordinary commands still run (%s)", content);
+
+ stop_worker(&ip, pid);
+
+ /* ---- allow_subprocess_net: the network is permitted at pledge ---- */
+ pid = spawn_worker(1, td, &ip);
+
+ send_req(&ip, "y1", "shell", FTP_PROBE);
+ recv_result(&ip, content, sizeof(content), &is_err);
+ CHECK(strstr(content, "pledge: Operation not permitted") == NULL,
+ "subproc-net: ftp is not pledge-denied (%s)", content);
+
+ stop_worker(&ip, pid);
+
+ unlink(escape);
+ rmdir(td);
+
+ printf("sandbox_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 524cf3327ed5273977877a793bd703692d21fdb0 (mode 644)
--- /dev/null
+++ regress/skills/Makefile
+# Unit tests for the skills loader/match/complete (src/ui/skills.c).
+
+PROG= skills_test
+SRCS= skills_test.c skills.c json.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+UIDIR= ${.CURDIR}/../../src/ui
+CFLAGS+= -I${COMMONDIR} -I${UIDIR}
+.PATH: ${COMMONDIR} ${UIDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-skills_test
+
+run-skills_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 1cdba1fc1bc799035aa80854b43c8ac30d8d7bf1 (mode 644)
--- /dev/null
+++ regress/skills/skills_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the skills loader. Builds a throwaway ~/.fugu/skills layout
+ * under a temp HOME, exercises skills_load on it, then checks the registry
+ * shape, match/complete behaviour, and the journal marker.
+ */
+
+#include <sys/stat.h>
+
+#include <err.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "json.h"
+#include "skills.h"
+
+static int fails;
+static int checks;
+
+#define CHECK(cond, msg) do { \
+ checks++; \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", \
+ __func__, __LINE__, (msg)); \
+ fails++; \
+ } \
+} while (0)
+
+static void
+check_str(const char *func, int line, const char *got, const char *want,
+ const char *msg)
+{
+ checks++;
+ if (got == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s:%d: %s\n got: %s\n want: %s\n",
+ func, line, msg, got != NULL ? got : "(null)", want);
+ fails++;
+ }
+}
+
+#define CHECK_STR(got, want, msg) \
+ check_str(__func__, __LINE__, (got), (want), (msg))
+
+/* Write a file (creating parent dirs as needed under the temp HOME). */
+static void
+drop_skill(const char *home, const char *dirname, const char *content)
+{
+ char path[1024];
+ FILE *f;
+
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/%s", home, dirname);
+ if (mkdir(path, 0755) == -1 && errno != EEXIST)
+ err(1, "mkdir %s", path);
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/%s/SKILL.md",
+ home, dirname);
+ if ((f = fopen(path, "w")) == NULL)
+ err(1, "fopen %s", path);
+ if (content != NULL)
+ fputs(content, f);
+ fclose(f);
+}
+
+static char *
+locate_skill(const struct skills *sk, const char *slash_name)
+{
+ size_t i;
+
+ for (i = 0; i < sk->n; i++)
+ if (strcmp(sk->items[i].name, slash_name) == 0)
+ return (sk->items[i].body);
+ return (NULL);
+}
+
+static void
+test_load_basic(const char *home)
+{
+ struct skills sk;
+ const struct skill *m;
+ const char *trailing;
+ const char *matches[8];
+ int n;
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ /* Three valid skills were dropped; the malformed and empty-body skills are skipped. */
+ CHECK(sk.n == 3, "loaded count = 3");
+
+ /* Alphabetical order. */
+ if (sk.n >= 3) {
+ CHECK_STR(sk.items[0].name, "/alpha", "first sorted");
+ CHECK_STR(sk.items[1].name, "/bravo", "second sorted");
+ CHECK_STR(sk.items[2].name, "/named", "header name wins");
+ }
+
+ /* Body trimming + content. */
+ CHECK_STR(locate_skill(&sk, "/alpha"), "alpha body", "alpha body");
+ CHECK_STR(locate_skill(&sk, "/bravo"),
+ "bravo body\nsecond line", "bravo body multi-line");
+
+ /* skills_match: exact, with trailing, no leading slash, miss. */
+ m = skills_match(&sk, "/alpha", &trailing);
+ CHECK(m != NULL && strcmp(m->name, "/alpha") == 0, "match /alpha");
+ CHECK(trailing == NULL, "no trailing for bare name");
+
+ m = skills_match(&sk, "/bravo rest of line", &trailing);
+ CHECK(m != NULL && strcmp(m->name, "/bravo") == 0, "match /bravo with arg");
+ CHECK_STR(trailing, "rest of line", "trailing captured");
+
+ m = skills_match(&sk, "bravo", &trailing);
+ CHECK(m == NULL, "no match without leading slash");
+
+ m = skills_match(&sk, "/nope", &trailing);
+ CHECK(m == NULL, "no match for unknown");
+
+ m = skills_match(&sk, "/alphabet", &trailing);
+ CHECK(m == NULL, "no prefix match (name must equal first token)");
+
+ /* skills_complete. */
+ n = skills_complete(&sk, "/", matches, 8);
+ CHECK(n == 3, "complete '/' returns all three");
+
+ n = skills_complete(&sk, "/b", matches, 8);
+ CHECK(n == 1 && strcmp(matches[0], "/bravo") == 0, "complete '/b' -> bravo");
+
+ n = skills_complete(&sk, "/z", matches, 8);
+ CHECK(n == 0, "no match completes to 0");
+
+ skills_free(&sk);
+ CHECK(sk.n == 0 && sk.items == NULL, "free zeroes registry");
+}
+
+static void
+test_marker(void)
+{
+ struct buf b;
+
+ buf_init(&b);
+ skills_marker(&b, "/triage");
+ buf_terminate(&b);
+ CHECK_STR(b.data, "{\"fugu\":\"skill\",\"name\":\"/triage\"}",
+ "marker shape");
+ buf_free(&b);
+}
+
+/* The tool fragment registers `skill` as a real tool the model can invoke. */
+static void
+test_tool_fragment(void)
+{
+ struct skills sk;
+ struct buf b;
+ struct json_doc d;
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ buf_init(&b);
+ skills_tool_fragment(&sk, &b);
+ CHECK(b.len > 0, "fragment is non-empty when skills are loaded");
+ CHECK(b.data[0] == ',', "fragment is comma-prefixed for splicing");
+
+ /*
+ * Parse the fragment by wrapping it in array brackets (with a sentinel
+ * placeholder so the leading comma is between two elements) -- the
+ * parser checks well-formedness end-to-end.
+ */
+ {
+ struct buf wrap;
+
+ buf_init(&wrap);
+ buf_puts_cstr(&wrap, "[1");
+ buf_append(&wrap, b.data, b.len);
+ buf_putc(&wrap, ']');
+ CHECK(json_parse(&d, wrap.data, wrap.len) == 0 &&
+ json_tok_type(&d, 0) == JSON_ARRAY,
+ "fragment is well-formed JSON when spliced");
+ json_doc_free(&d);
+ buf_free(&wrap);
+ }
+
+ /* Contains the tool name and at least one expected skill in the enum. */
+ CHECK(strstr(b.data, "\"name\":\"skill\"") != NULL,
+ "fragment names the `skill` tool");
+ CHECK(strstr(b.data, "\"alpha\"") != NULL &&
+ strstr(b.data, "\"bravo\"") != NULL,
+ "fragment enumerates loaded skill names");
+ CHECK(strstr(b.data, "alpha: first one") != NULL,
+ "fragment includes the description for matching");
+
+ buf_free(&b);
+ skills_free(&sk);
+}
+
+/*
+ * The synth triple is a user(typed slash) + assistant(tool_use) +
+ * user(tool_result) sequence sharing the generated tool_use_id, with the body
+ * delivered as the tool_result content. The leading user message ensures the
+ * messages array starts with a user role even on the first turn or right after
+ * /compact.
+ */
+static void
+test_synth_invocation(void)
+{
+ struct skills sk;
+ const struct skill *s;
+ struct buf up, am, um;
+ struct json_doc d;
+ char id[40];
+ const char *trailing;
+ int role_t, content_t, blk0, type_t, id_t;
+ char *name_t, *got;
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+ s = skills_match(&sk, "/alpha", &trailing);
+ CHECK(s != NULL, "synth: located /alpha");
+ if (s == NULL) {
+ skills_free(&sk);
+ return;
+ }
+
+ buf_init(&up);
+ buf_init(&am);
+ buf_init(&um);
+ skills_synth_invocation(s, "/alpha the rest", "the rest",
+ &up, &am, &um, id, sizeof(id));
+
+ CHECK(id[0] != '\0', "synth: generated a non-empty id");
+
+ /* User-pre message: role=user, content == typed slash command. */
+ CHECK(json_parse(&d, up.data, up.len) == 0,
+ "synth: user-pre is valid JSON");
+ role_t = json_obj_get(&d, 0, "role");
+ CHECK(role_t > 0 && json_tok_streq(&d, role_t, "user"),
+ "synth: user-pre role is user");
+ got = json_tok_strdup(&d, json_obj_get(&d, 0, "content"));
+ CHECK_STR(got, "/alpha the rest",
+ "synth: user-pre content is the typed slash command");
+ free(got);
+ json_doc_free(&d);
+
+ /* Assistant message: tool_use(skill, name="alpha"), id matches. */
+ CHECK(json_parse(&d, am.data, am.len) == 0,
+ "synth: assistant is valid JSON");
+ content_t = json_obj_get(&d, 0, "content");
+ CHECK(content_t > 0 && json_tok_type(&d, content_t) == JSON_ARRAY,
+ "synth: assistant content is an array");
+ blk0 = content_t + 1;
+ type_t = json_obj_get(&d, blk0, "type");
+ CHECK(type_t > 0 && json_tok_streq(&d, type_t, "tool_use"),
+ "synth: first block is tool_use");
+ name_t = json_tok_strdup(&d, json_obj_get(&d, blk0, "name"));
+ CHECK_STR(name_t, "skill", "synth: tool_use names `skill`");
+ free(name_t);
+ id_t = json_obj_get(&d, blk0, "id");
+ CHECK(id_t > 0 && json_tok_streq(&d, id_t, id),
+ "synth: tool_use id matches generated id");
+ json_doc_free(&d);
+
+ /* User message: tool_result with matching tool_use_id and body+trailing. */
+ CHECK(json_parse(&d, um.data, um.len) == 0,
+ "synth: user is valid JSON");
+ content_t = json_obj_get(&d, 0, "content");
+ blk0 = content_t + 1;
+ type_t = json_obj_get(&d, blk0, "type");
+ CHECK(type_t > 0 && json_tok_streq(&d, type_t, "tool_result"),
+ "synth: first user block is tool_result");
+ id_t = json_obj_get(&d, blk0, "tool_use_id");
+ CHECK(id_t > 0 && json_tok_streq(&d, id_t, id),
+ "synth: tool_result id matches tool_use id");
+ got = json_tok_strdup(&d, json_obj_get(&d, blk0, "content"));
+ CHECK_STR(got, "alpha body\n\nthe rest",
+ "synth: tool_result content is body + trailing");
+ free(got);
+ json_doc_free(&d);
+
+ buf_free(&up);
+ buf_free(&am);
+ buf_free(&um);
+ skills_free(&sk);
+}
+
+/* Model-issued tool_use("skill", name=...) -> body or is_error. */
+static void
+test_handle_tool_request(void)
+{
+ struct skills sk;
+ struct buf out;
+ struct json_doc d;
+ char *content;
+ const char *req_ok = "{\"id\":\"x1\",\"name\":\"skill\","
+ "\"input\":{\"name\":\"alpha\"}}";
+ const char *req_bad = "{\"id\":\"x2\",\"name\":\"skill\","
+ "\"input\":{\"name\":\"nope\"}}";
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ /* Hit. */
+ buf_init(&out);
+ skills_handle_tool_request(&sk, req_ok, strlen(req_ok), &out);
+ CHECK(json_parse(&d, out.data, out.len) == 0,
+ "handle: hit result is valid JSON");
+ content = json_tok_strdup(&d, json_obj_get(&d, 0, "content"));
+ CHECK_STR(content, "alpha body", "handle: hit content is body");
+ free(content);
+ {
+ int t = json_obj_get(&d, 0, "is_error");
+ CHECK(t > 0 && json_tok_streq(&d, t, "false"),
+ "handle: hit is not an error");
+ }
+ json_doc_free(&d);
+ buf_free(&out);
+
+ /* Miss. */
+ buf_init(&out);
+ skills_handle_tool_request(&sk, req_bad, strlen(req_bad), &out);
+ CHECK(json_parse(&d, out.data, out.len) == 0,
+ "handle: miss result is valid JSON");
+ {
+ int t = json_obj_get(&d, 0, "is_error");
+ CHECK(t > 0 && json_tok_streq(&d, t, "true"),
+ "handle: miss is an error");
+ }
+ json_doc_free(&d);
+ buf_free(&out);
+
+ skills_free(&sk);
+}
+
+/* disable-model-invocation excludes the skill from the tool's enum. */
+static void
+test_disable_model_invocation(const char *home)
+{
+ struct skills sk;
+ struct buf b;
+ char path[1024];
+
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+ mkdir(path, 0755);
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/private/SKILL.md", home);
+ {
+ FILE *f = fopen(path, "w");
+
+ fputs("---\nname: private\ndisable-model-invocation: true\n---\n"
+ "secret\n", f);
+ fclose(f);
+ }
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ /* Skill loads, slash-invocation still works. */
+ CHECK(skills_match(&sk, "/private", NULL) != NULL,
+ "disabled skill still user-invocable via slash");
+
+ /* But the tool fragment must not include it. */
+ buf_init(&b);
+ skills_tool_fragment(&sk, &b);
+ CHECK(strstr(b.data, "\"private\"") == NULL,
+ "disabled skill is excluded from the tool enum");
+ CHECK(strstr(b.data, "private: ") == NULL,
+ "disabled skill is excluded from the description");
+
+ buf_free(&b);
+ skills_free(&sk);
+
+ /*
+ * Clean up the on-disk skill we dropped so later tests (or future
+ * tests added between this one and test_no_home) still see exactly
+ * the three skills set up in main().
+ */
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/private/SKILL.md", home);
+ (void)unlink(path);
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+ (void)rmdir(path);
+}
+
+/*
+ * A skill with disable-model-invocation MUST NOT be reachable via the model's
+ * tool_use("skill", name=...) path -- even though it loads and is reachable
+ * via the user's slash command. The handler returns an is_error result naming
+ * the skill as not found.
+ */
+static void
+test_disabled_skill_not_invocable(const char *home)
+{
+ struct skills sk;
+ struct buf out;
+ struct json_doc d;
+ const char *req = "{\"id\":\"x9\",\"name\":\"skill\","
+ "\"input\":{\"name\":\"private\"}}";
+ int t;
+ char path[1024];
+
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+ mkdir(path, 0755);
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/private/SKILL.md", home);
+ {
+ FILE *f = fopen(path, "w");
+
+ fputs("---\nname: private\ndisable-model-invocation: true\n"
+ "---\nsecret body\n", f);
+ fclose(f);
+ }
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ buf_init(&out);
+ skills_handle_tool_request(&sk, req, strlen(req), &out);
+ CHECK(json_parse(&d, out.data, out.len) == 0,
+ "disabled-tool: result is valid JSON");
+ t = json_obj_get(&d, 0, "is_error");
+ CHECK(t > 0 && json_tok_streq(&d, t, "true"),
+ "disabled-tool: result is_error=true");
+ json_doc_free(&d);
+ buf_free(&out);
+
+ skills_free(&sk);
+
+ /* Clean up the on-disk private skill so later tests see a fresh registry. */
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/private/SKILL.md", home);
+ (void)unlink(path);
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+ (void)rmdir(path);
+}
+
+/*
+ * A SKILL.md whose body carries an internal NUL byte must be refused at load
+ * time -- otherwise xstrdup() would silently truncate and the model would see
+ * a half-body.
+ */
+static void
+test_skill_with_internal_nul(const char *home)
+{
+ struct skills sk;
+ char path[1024];
+ FILE *f;
+ size_t i;
+ int found = 0;
+ static const char hdr[] = "---\nname: nulbody\n---\n";
+ static const char body[] = "AAA\0BBB\n";
+
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/nulbody", home);
+ mkdir(path, 0755);
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/nulbody/SKILL.md", home);
+ if ((f = fopen(path, "w")) == NULL)
+ err(1, "fopen %s", path);
+ fwrite(hdr, 1, sizeof(hdr) - 1, f);
+ fwrite(body, 1, sizeof(body) - 1, f);
+ fclose(f);
+
+ memset(&sk, 0, sizeof(sk));
+ skills_load(&sk);
+
+ for (i = 0; i < sk.n; i++)
+ if (strcmp(sk.items[i].name, "/nulbody") == 0)
+ found = 1;
+ CHECK(!found, "internal NUL: skill is rejected at load time");
+
+ skills_free(&sk);
+
+ /* Clean up so later tests are not affected by this skill. */
+ (void)snprintf(path, sizeof(path),
+ "%s/.fugu/skills/nulbody/SKILL.md", home);
+ unlink(path);
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/nulbody", home);
+ rmdir(path);
+}
+
+static void
+test_no_home(void)
+{
+ struct skills sk;
+ char *saved;
+
+ memset(&sk, 0, sizeof(sk));
+ saved = getenv("HOME");
+ if (saved != NULL)
+ saved = strdup(saved);
+ unsetenv("HOME");
+ skills_load(&sk);
+ CHECK(sk.n == 0, "no HOME -> empty registry");
+ skills_free(&sk);
+ if (saved != NULL) {
+ setenv("HOME", saved, 1);
+ free(saved);
+ }
+}
+
+int
+main(void)
+{
+ char tmpl[] = "/tmp/fugu-skills-test.XXXXXX";
+ char *home;
+ char path[1024];
+
+ if ((home = mkdtemp(tmpl)) == NULL)
+ err(1, "mkdtemp");
+ (void)snprintf(path, sizeof(path), "%s/.fugu", home);
+ mkdir(path, 0755);
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills", home);
+ mkdir(path, 0755);
+
+ /* Three valid skills (one with header name, two using dirname). */
+ drop_skill(home, "alpha",
+ "---\nname: alpha\ndescription: first one\n---\nalpha body\n");
+ drop_skill(home, "bravo", "bravo body\nsecond line\n");
+ drop_skill(home, "from-dirname",
+ "---\nname: named\n---\nbody for the named one\n");
+
+ /* Malformed/empty -- should be silently skipped. */
+ drop_skill(home, "empty-body",
+ "---\nname: emp\n---\n\n \n");
+ drop_skill(home, "unterminated-header",
+ "---\nname: bad\nno closing fence ever follows\n");
+ (void)snprintf(path, sizeof(path), "%s/.fugu/skills/.dotdir", home);
+ mkdir(path, 0755); /* dotfiles ignored */
+
+ setenv("HOME", home, 1);
+ test_load_basic(home);
+ test_marker();
+ test_tool_fragment();
+ test_synth_invocation();
+ test_handle_tool_request();
+ test_disable_model_invocation(home);
+ test_disabled_skill_not_invocable(home);
+ test_skill_with_internal_nul(home);
+ test_no_home();
+
+ /* Cleanup (best-effort). */
+ {
+ char cmd[1100];
+
+ (void)snprintf(cmd, sizeof(cmd), "rm -rf %s", home);
+ (void)system(cmd);
+ }
+
+ printf("skills_test: %d checks, %d failures\n", checks, fails);
+ return (fails == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 2922a3b397de0e899c6a4669b68d0211aff08847 (mode 644)
--- /dev/null
+++ regress/sse/Makefile
+# Unit tests for the Server-Sent Events parser (src/common/sse.c).
+
+PROG= sse_test
+SRCS= sse_test.c sse.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-sse_test
+
+run-sse_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 815a632d82a59c4a5a4a357444feba94aee4f67d (mode 644)
--- /dev/null
+++ regress/sse/sse_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "sse.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+#define MAXEV 32
+
+static struct {
+ char *event;
+ char *data;
+ size_t datalen;
+} evs[MAXEV];
+static int nev;
+
+static void
+on_event(const char *event, const char *data, size_t datalen, void *arg)
+{
+ (void)arg;
+ if (nev >= MAXEV) {
+ failures++;
+ return;
+ }
+ evs[nev].event = strdup(event);
+ evs[nev].data = malloc(datalen + 1);
+ memcpy(evs[nev].data, data, datalen);
+ evs[nev].data[datalen] = '\0';
+ evs[nev].datalen = datalen;
+ nev++;
+}
+
+static void
+reset_events(void)
+{
+ int i;
+
+ for (i = 0; i < nev; i++) {
+ free(evs[i].event);
+ free(evs[i].data);
+ }
+ nev = 0;
+}
+
+static void
+push_str(struct sse_parser *p, const char *s)
+{
+ if (sse_push(p, s, strlen(s)) != 0)
+ failures++;
+}
+
+int
+main(void)
+{
+ struct sse_parser p;
+
+ /* 1. event + data */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "event: ping\ndata: hello\n\n");
+ CHECK(nev == 1, "basic: nev=%d", nev);
+ if (nev == 1) {
+ CHECK(strcmp(evs[0].event, "ping") == 0, "basic event");
+ CHECK(strcmp(evs[0].data, "hello") == 0, "basic data");
+ CHECK(evs[0].datalen == 5, "basic datalen=%zu", evs[0].datalen);
+ }
+ sse_clear(&p);
+ reset_events();
+
+ /* 2. default event type is "message" */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: hi\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].event, "message") == 0, "default event");
+ CHECK(nev == 1 && strcmp(evs[0].data, "hi") == 0, "default data");
+ sse_clear(&p);
+ reset_events();
+
+ /* 3. multiple data lines are joined with '\n' */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: a\ndata: b\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, "a\nb") == 0, "multi-data join");
+ sse_clear(&p);
+ reset_events();
+
+ /* 4. comment lines are ignored */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, ": keep-alive\ndata: x\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, "x") == 0, "comment ignored");
+ sse_clear(&p);
+ reset_events();
+
+ /* 5. CRLF line endings */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "event: e\r\ndata: d\r\n\r\n");
+ CHECK(nev == 1 && strcmp(evs[0].event, "e") == 0 &&
+ strcmp(evs[0].data, "d") == 0, "crlf endings");
+ sse_clear(&p);
+ reset_events();
+
+ /* 6. exactly one leading space after the colon is stripped */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: x\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, " x") == 0, "one-space strip");
+ sse_clear(&p);
+ reset_events();
+
+ /* 7. embedded colons in the value (JSON) are preserved */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: {\"a\":1,\"b\":2}\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, "{\"a\":1,\"b\":2}") == 0,
+ "json colons");
+ sse_clear(&p);
+ reset_events();
+
+ /* 8. an event split across two pushes */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: hel");
+ push_str(&p, "lo\n\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, "hello") == 0, "split feed");
+ sse_clear(&p);
+ reset_events();
+
+ /* 9. a blank line with no data dispatches nothing */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "event: x\n\n");
+ CHECK(nev == 0, "no-data no-dispatch: nev=%d", nev);
+ sse_clear(&p);
+ reset_events();
+
+ /* 10. an empty data value yields a zero-length payload */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data:\n\n");
+ CHECK(nev == 1 && evs[0].datalen == 0, "empty data value");
+ sse_clear(&p);
+ reset_events();
+
+ /* 11. bare-CR line endings */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: r\r\r");
+ CHECK(nev == 1 && strcmp(evs[0].data, "r") == 0, "bare-cr endings");
+ sse_clear(&p);
+ reset_events();
+
+ /* 12. two events back to back */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: a\n\ndata: b\n\n");
+ CHECK(nev == 2, "two events: nev=%d", nev);
+ if (nev == 2) {
+ CHECK(strcmp(evs[0].data, "a") == 0, "two events first");
+ CHECK(strcmp(evs[1].data, "b") == 0, "two events second");
+ }
+ sse_clear(&p);
+ reset_events();
+
+ /* 13b. a CRLF split across two pushes (CR ends one, LF starts next) */
+ sse_init(&p, on_event, NULL);
+ push_str(&p, "data: split\r");
+ push_str(&p, "\n\r\n");
+ CHECK(nev == 1 && strcmp(evs[0].data, "split") == 0,
+ "crlf split across push");
+ sse_clear(&p);
+ reset_events();
+
+ /* 13. an Anthropic-shaped stream */
+ sse_init(&p, on_event, NULL);
+ push_str(&p,
+ "event: message_start\n"
+ "data: {\"type\":\"message_start\"}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n");
+ CHECK(nev == 4, "anthropic: nev=%d", nev);
+ if (nev == 4) {
+ CHECK(strcmp(evs[0].event, "message_start") == 0, "anth ev0");
+ CHECK(strcmp(evs[1].event, "content_block_delta") == 0,
+ "anth ev1");
+ CHECK(strstr(evs[1].data, "Hello") != NULL, "anth ev1 text");
+ CHECK(strcmp(evs[3].event, "message_stop") == 0, "anth ev3");
+ }
+ sse_clear(&p);
+ reset_events();
+
+ /* 14. an event that never terminates is capped, not grown forever */
+ sse_init(&p, on_event, NULL);
+ p.maxtotal = 1000;
+ {
+ int r = 0, k;
+
+ for (k = 0; k < 2000 && r == 0; k++)
+ r = sse_push(&p, "data: x\n", 8);
+ CHECK(r == -1, "data cap enforced");
+ CHECK(nev == 0, "data cap: nothing dispatched (nev=%d)", nev);
+ }
+ sse_clear(&p);
+ reset_events();
+
+ printf("sse_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 3d8cad373ca2e72ad1cebeed1b3d55164986de4f (mode 644)
--- /dev/null
+++ regress/stream/Makefile
+# Integration test: chunked HTTP carrying an SSE stream (http.c + sse.c).
+
+PROG= stream_test
+SRCS= stream_test.c http.c sse.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+REGRESS_TARGETS= run-stream_test
+
+run-stream_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6df085c99c0018c6a5f5371d0105f751a2be6715 (mode 644)
--- /dev/null
+++ regress/stream/stream_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Integration: a chunked HTTP/1.1 response whose body is an Anthropic-shaped
+ * text/event-stream, fed to the parser in tiny pieces. http de-chunks into
+ * the SSE parser, which frames events across both the network-read boundaries
+ * and the HTTP chunk boundaries. Proves the portable half of the net path end
+ * to end (JSON interpretation of each event is the net worker's job, later).
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "sse.h"
+
+static int checks;
+static int failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+#define MAXEV 16
+
+static char *ev_name[MAXEV];
+static char *ev_data[MAXEV];
+static int nev;
+
+static void
+on_event(const char *event, const char *data, size_t datalen, void *arg)
+{
+ (void)arg;
+ if (nev >= MAXEV) {
+ failures++;
+ return;
+ }
+ ev_name[nev] = strdup(event);
+ ev_data[nev] = malloc(datalen + 1);
+ memcpy(ev_data[nev], data, datalen);
+ ev_data[nev][datalen] = '\0';
+ nev++;
+}
+
+/* http body callback: forward decoded body bytes into the SSE parser. */
+static void
+on_body(const void *d, size_t n, void *arg)
+{
+ struct sse_parser *sse = arg;
+
+ if (sse_push(sse, d, n) != 0)
+ failures++;
+}
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+ buf_printf(b, "%zx\r\n", n);
+ buf_append(b, data, n);
+ buf_append(b, "\r\n", 2);
+}
+
+int
+main(void)
+{
+ struct http_resp r;
+ struct sse_parser sse;
+ struct buf resp;
+ const char *sse_body =
+ "event: message_start\n"
+ "data: {\"type\":\"message_start\"}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\", world!\"}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n";
+ size_t bl, mid, off, n;
+
+ sse_init(&sse, on_event, NULL);
+ http_resp_init(&r, on_body, &sse);
+
+ /* Build a chunked response, splitting the SSE body mid-stream so a
+ * chunk boundary falls inside an event. */
+ bl = strlen(sse_body);
+ mid = bl / 2;
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+ buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n");
+ buf_puts_cstr(&resp, "\r\n");
+ add_chunk(&resp, sse_body, mid);
+ add_chunk(&resp, sse_body + mid, bl - mid);
+ buf_puts_cstr(&resp, "0\r\n\r\n");
+
+ /* Feed the whole thing through http in 5-byte network-sized pieces. */
+ for (off = 0; off < resp.len; off += n) {
+ n = resp.len - off;
+ if (n > 5)
+ n = 5;
+ if (http_resp_push(&r, resp.data + off, n) == -1) {
+ CHECK(0, "http push failed at off=%zu", off);
+ break;
+ }
+ }
+
+ CHECK(http_resp_done(&r), "stream: response not complete");
+ CHECK(r.status == 200, "stream: status=%d", r.status);
+ CHECK(nev == 4, "stream: nev=%d", nev);
+ if (nev == 4) {
+ CHECK(strcmp(ev_name[0], "message_start") == 0, "stream ev0");
+ CHECK(strcmp(ev_name[1], "content_block_delta") == 0,
+ "stream ev1 name");
+ CHECK(strstr(ev_data[1], "\"text\":\"Hello\"") != NULL,
+ "stream ev1 text");
+ CHECK(strstr(ev_data[2], ", world!") != NULL,
+ "stream ev2 text");
+ CHECK(strcmp(ev_name[3], "message_stop") == 0, "stream ev3");
+ }
+
+ for (off = 0; off < (size_t)nev; off++) {
+ free(ev_name[off]);
+ free(ev_data[off]);
+ }
+ http_resp_clear(&r);
+ sse_clear(&sse);
+ buf_free(&resp);
+
+ printf("stream_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + ca0c06fea4d8edf222d96ee0478588d7658ea04f (mode 644)
--- /dev/null
+++ regress/tlsconn/Makefile
+# Regression tests for src/common/tlsconn.c. Drives the failure paths
+# (connect-to-nothing, TCP-accept-then-close) directly -- no full
+# net_loop choreography -- and asserts the contract documented in
+# tlsconn.h: *errstr is heap-allocated and outlives tls_free / tls_close.
+
+PROG= tlsconn_test
+SRCS= tlsconn_test.c tlsconn.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS= run-tlsconn_test
+
+run-tlsconn_test: ${PROG}
+ ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 010a439baa44b1b3fd4ca54d4458eb40e47dfb69 (mode 644)
--- /dev/null
+++ regress/tlsconn/tlsconn_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Regression tests for tlsconn_connect's documented contract:
+ *
+ * "*errstr is heap-allocated and NUL-terminated; the caller free()s it
+ * because the underlying libtls context-owned buffer is released before
+ * the function returns."
+ *
+ * The motivating bug (pre-fix) was a use-after-free: *errstr was set to
+ * tls_error(ctx) -- a borrowed pointer into context-owned memory -- and
+ * then tls_free() released that memory before returning to the caller. On
+ * OpenBSD, default malloc.conf junk-fills freed allocations with 0xDB, so a
+ * later read would see garbage; this regress proves the fix by capturing
+ * the error string, churning the heap aggressively, and re-reading.
+ *
+ * Two failure paths are exercised:
+ *
+ * Mode 2 (TCP-accept-then-close): a fork()ed stub binds port 0, accepts
+ * the connection, and immediately closes the socket. tlsconn_connect's
+ * tls_connect or tls_handshake fails inside libtls with a real error
+ * message; this is the closest analogue to a peer dropping mid-handshake.
+ *
+ * Mode 3 (connect-to-nothing): point tlsconn_connect at a port nothing
+ * is listening on. tls_connect fails at the TCP layer with a different
+ * libtls error. No stub needed.
+ *
+ * Mode 1 (cert mismatch) would need a real cert+key; out of scope for
+ * this initial harness. Add later as a separate test alongside the
+ * Mode 2 stub if peer-verify failure modes need coverage.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "tlsconn.h"
+
+static int checks, failures;
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+/*
+ * Churn the heap aggressively to force OpenBSD's junk-on-free to overwrite
+ * any region a stale tls_error() pointer might have referenced. If the
+ * fixed *errstr ownership ever regresses, the captured bytes will no
+ * longer match after this call -- the test then fails loudly. Each
+ * iteration allocates a fresh region, writes a recognisable byte to every
+ * byte, then frees, so the entire allocation is dirtied before the
+ * allocator can hand it out again.
+ */
+static void
+heap_churn(void)
+{
+ enum { ROUNDS = 32, BLOCK = 64 * 1024 };
+ int i;
+
+ for (i = 0; i < ROUNDS; i++) {
+ void *p = malloc(BLOCK);
+
+ if (p == NULL)
+ continue;
+ memset(p, 0xA5, BLOCK);
+ free(p);
+ }
+}
+
+/*
+ * Common assertions for a -1 return from tlsconn_connect: errstr is a
+ * heap string, non-empty, and survives heap churn (the post-fix
+ * ownership contract). Frees errstr at the end so the test can call
+ * this repeatedly without leaking. Returns 1 if every assertion held.
+ */
+static int
+assert_errstr_owned(const char *what, char *err)
+{
+ size_t len_before;
+ char saved[256];
+ int ok = 1;
+
+ if (err == NULL) {
+ CHECK(0, "%s: errstr is NULL after -1 return", what);
+ return (0);
+ }
+ len_before = strlen(err);
+ if (len_before == 0) {
+ CHECK(0, "%s: errstr is empty after -1 return", what);
+ free(err);
+ return (0);
+ }
+ strlcpy(saved, err, sizeof(saved));
+
+ heap_churn();
+
+ if (strlen(err) != len_before) {
+ CHECK(0, "%s: errstr length changed across heap churn "
+ "(was %zu, now %zu) -- looks like a use-after-free",
+ what, len_before, strlen(err));
+ ok = 0;
+ } else {
+ CHECK(1, "%s: errstr length preserved (%zu)", what, len_before);
+ }
+ if (strcmp(err, saved) != 0) {
+ CHECK(0, "%s: errstr bytes changed across heap churn "
+ "(was \"%s\", now \"%s\") -- USE-AFTER-FREE",
+ what, saved, err);
+ ok = 0;
+ } else {
+ CHECK(1, "%s: errstr bytes preserved across heap churn", what);
+ }
+ free(err);
+ return (ok);
+}
+
+/*
+ * Mode 3: connect to a port nothing is listening on. tls_connect fails
+ * at the TCP layer; tlsconn_connect's first fail-label runs (the one
+ * after tls_connect). Validates the most common production failure
+ * shape (peer down). Uses an explicit high-port that is overwhelmingly
+ * likely to be unbound (close to ephemeral-range top, no privileged-port
+ * concerns).
+ */
+static void
+test_mode3_connect_to_nothing(void)
+{
+ struct tls_config *cfg;
+ struct tlsconn c;
+ char *err = NULL;
+ int rc;
+
+ cfg = tlsconn_client_config(NULL);
+ if (cfg == NULL) {
+ CHECK(0, "tlsconn_client_config(NULL) failed");
+ return;
+ }
+
+ rc = tlsconn_connect(&c, cfg, "127.0.0.1", "1", &err);
+ CHECK(rc == -1, "Mode 3: connect to unbound port returns -1 (got %d)",
+ rc);
+ (void)assert_errstr_owned("Mode 3", err);
+
+ CHECK(c.fd == -1, "Mode 3: c.fd cleared to -1 on failure (got %d)",
+ c.fd);
+ CHECK(c.ctx == NULL, "Mode 3: c.ctx NULLed on failure");
+
+ tls_config_free(cfg);
+}
+
+/*
+ * Bind a fresh TCP socket on 127.0.0.1:0 and return the kernel-assigned
+ * port through *out_port. The caller owns the fd and is responsible for
+ * passing it to the fork()ed stub (which closes the parent's copy after
+ * fork). Returns -1 if any of the setup syscalls fail.
+ */
+static int
+bind_loopback_listener(int *out_port)
+{
+ struct sockaddr_in sa;
+ socklen_t slen = sizeof(sa);
+ int fd, on = 1;
+
+ if ((fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ return (-1);
+ (void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_port = 0; /* kernel picks */
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) == -1 ||
+ listen(fd, 4) == -1 ||
+ getsockname(fd, (struct sockaddr *)&sa, &slen) == -1) {
+ close(fd);
+ return (-1);
+ }
+ *out_port = ntohs(sa.sin_port);
+ return (fd);
+}
+
+/*
+ * Mode 2: a stub that accept()s the TCP connection and then immediately
+ * close()s without exchanging any TLS bytes. tlsconn_connect's tls
+ * handshake either fails outright (peer closed) or fails to read the
+ * ServerHello. Either way libtls reports a real error message.
+ *
+ * Fork model: parent runs tlsconn_connect, child runs accept+close. The
+ * child SIGTERMs back to the parent if anything goes wrong so a failure
+ * is visible rather than hanging.
+ */
+static void
+test_mode2_accept_then_close(void)
+{
+ struct tls_config *cfg;
+ struct tlsconn c;
+ char *err = NULL;
+ char portstr[16];
+ pid_t stub;
+ int listen_fd, port, rc, status;
+
+ if ((listen_fd = bind_loopback_listener(&port)) == -1) {
+ CHECK(0, "bind_loopback_listener: %s", strerror(errno));
+ return;
+ }
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if ((stub = fork()) == -1) {
+ close(listen_fd);
+ CHECK(0, "fork: %s", strerror(errno));
+ return;
+ }
+ if (stub == 0) {
+ /* child: accept ONE then drop without TLS bytes. */
+ int a = accept(listen_fd, NULL, NULL);
+
+ if (a >= 0)
+ close(a);
+ close(listen_fd);
+ _exit(0);
+ }
+ close(listen_fd); /* parent does not need the listener */
+
+ cfg = tlsconn_client_config(NULL);
+ if (cfg == NULL) {
+ CHECK(0, "tlsconn_client_config(NULL) failed");
+ (void)kill(stub, SIGTERM);
+ (void)waitpid(stub, &status, 0);
+ return;
+ }
+
+ rc = tlsconn_connect(&c, cfg, "127.0.0.1", portstr, &err);
+ CHECK(rc == -1, "Mode 2: accept-then-close returns -1 (got %d)", rc);
+ (void)assert_errstr_owned("Mode 2", err);
+
+ CHECK(c.fd == -1, "Mode 2: c.fd cleared on failure");
+ CHECK(c.ctx == NULL, "Mode 2: c.ctx NULLed on failure");
+
+ tls_config_free(cfg);
+ (void)waitpid(stub, &status, 0);
+}
+
+/*
+ * Calling tlsconn_close on a never-connected (memset-zeroed) tlsconn must
+ * be a clean no-op. Pre-fix code that NULLs c->ctx in tlsconn_connect's
+ * fail label already covers the "we ran and failed" case; this asserts
+ * the freshly-initialised case is also safe.
+ */
+static void
+test_close_on_uninitialised(void)
+{
+ struct tlsconn c;
+
+ memset(&c, 0, sizeof(c));
+ c.fd = -1;
+ tlsconn_close(&c); /* must not crash */
+ CHECK(c.fd == -1, "close on uninitialised leaves fd -1");
+ CHECK(c.ctx == NULL, "close on uninitialised leaves ctx NULL");
+
+ tlsconn_close(&c); /* idempotent */
+ CHECK(c.fd == -1, "second close is idempotent (fd)");
+ CHECK(c.ctx == NULL, "second close is idempotent (ctx)");
+}
+
+/*
+ * Calling tlsconn_connect twice in a row -- a real path on retry-after-error
+ * code -- must produce two independently-owned errstr allocations, neither
+ * of which dangles into a shared libtls buffer. Frees both at the end and
+ * asserts they were distinct heap regions (different pointers) so a future
+ * regression that returned a static buffer would be caught.
+ */
+static void
+test_repeated_failures_owned_independently(void)
+{
+ struct tls_config *cfg = tlsconn_client_config(NULL);
+ struct tlsconn c1, c2;
+ char *e1 = NULL, *e2 = NULL;
+ int r1, r2;
+
+ if (cfg == NULL) {
+ CHECK(0, "tlsconn_client_config(NULL) failed");
+ return;
+ }
+
+ r1 = tlsconn_connect(&c1, cfg, "127.0.0.1", "1", &e1);
+ r2 = tlsconn_connect(&c2, cfg, "127.0.0.1", "1", &e2);
+ CHECK(r1 == -1 && r2 == -1, "both connects fail");
+ CHECK(e1 != NULL && e2 != NULL, "both errstrs populated");
+ CHECK(e1 != e2,
+ "two failures produce distinct heap pointers (no shared static)");
+ if (e1 != NULL && e2 != NULL && strlen(e1) > 0 && strlen(e2) > 0) {
+ /*
+ * The bytes should be identical (same failure mode, same
+ * libtls message), but the pointers must differ. This proves
+ * each call dup'd into its own allocation.
+ */
+ CHECK(strcmp(e1, e2) == 0,
+ "same failure mode yields same message text "
+ "(\"%s\" vs \"%s\")", e1, e2);
+ }
+ free(e1);
+ free(e2);
+ tls_config_free(cfg);
+}
+
+/*
+ * tlsconn_attach is the fetch-worker analog of tlsconn_connect: the caller
+ * has already dialed the TCP socket (with its own SSRF/timeout guard) and
+ * hands the fd in. Same *errstr ownership contract. Same heap-stability
+ * invariant. We exercise the FAILURE path -- a fork()ed stub that closes
+ * the socket before any TLS bytes flow -- and assert that on -1 the fd is
+ * NOT touched (the caller's responsibility to close it), since the
+ * asymmetric ownership rule is easy to break in future edits.
+ */
+static void
+test_attach_caller_keeps_fd_on_failure(void)
+{
+ struct tls_config *cfg;
+ struct tlsconn c;
+ struct sockaddr_in sa;
+ socklen_t slen = sizeof(sa);
+ char *err = NULL;
+ pid_t stub;
+ int listen_fd, sock, port, rc, status;
+
+ if ((listen_fd = bind_loopback_listener(&port)) == -1) {
+ CHECK(0, "bind_loopback_listener: %s", strerror(errno));
+ return;
+ }
+
+ if ((stub = fork()) == -1) {
+ close(listen_fd);
+ CHECK(0, "fork: %s", strerror(errno));
+ return;
+ }
+ if (stub == 0) {
+ int a = accept(listen_fd, NULL, NULL);
+
+ if (a >= 0)
+ close(a);
+ close(listen_fd);
+ _exit(0);
+ }
+ close(listen_fd);
+
+ /* The caller-dialed socket -- a plain blocking TCP connect. */
+ if ((sock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
+ CHECK(0, "socket: %s", strerror(errno));
+ (void)kill(stub, SIGTERM);
+ (void)waitpid(stub, &status, 0);
+ return;
+ }
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_port = htons(port);
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (connect(sock, (struct sockaddr *)&sa, slen) == -1) {
+ CHECK(0, "connect: %s", strerror(errno));
+ close(sock);
+ (void)waitpid(stub, &status, 0);
+ return;
+ }
+
+ cfg = tlsconn_client_config(NULL);
+ if (cfg == NULL) {
+ CHECK(0, "tlsconn_client_config(NULL) failed");
+ close(sock);
+ (void)waitpid(stub, &status, 0);
+ return;
+ }
+
+ rc = tlsconn_attach(&c, cfg, sock, "127.0.0.1", &err);
+ CHECK(rc == -1, "tlsconn_attach against closed peer returns -1 "
+ "(got %d)", rc);
+ CHECK(c.fd == -1,
+ "attach failure must leave c.fd at -1 (caller still owns fd) "
+ "(got %d)", c.fd);
+ CHECK(c.ctx == NULL, "attach failure leaves c.ctx NULL");
+ (void)assert_errstr_owned("tlsconn_attach", err);
+
+ /* The caller is still responsible for the fd. */
+ close(sock);
+ tls_config_free(cfg);
+ (void)waitpid(stub, &status, 0);
+}
+
+int
+main(void)
+{
+ if (tls_init() == -1) {
+ fprintf(stderr, "tls_init failed\n");
+ return (1);
+ }
+ /* Don't let a peer SIGPIPE us in the middle of a write loop. */
+ signal(SIGPIPE, SIG_IGN);
+
+ test_mode3_connect_to_nothing();
+ test_mode2_accept_then_close();
+ test_close_on_uninitialised();
+ test_repeated_failures_owned_independently();
+ test_attach_caller_keeps_fd_on_failure();
+
+ printf("tlsconn_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 70de1abaded0e9229b4a2dd8aa65f5dec9584cd0 (mode 644)
--- /dev/null
+++ regress/tui/Makefile
+# Smoke test for the curses front-end: run fugu(1) under a pty (src/ui/*).
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG= tui_test
+SRCS= tui_test.c buf.c util.c log.c
+NOMAN= yes
+
+COMMONDIR= ${.CURDIR}/../../src/common
+CFLAGS+= -I${COMMONDIR}
+.PATH: ${COMMONDIR}
+
+LDADD+= -ltls -lutil
+DPADD+= ${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN= ${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN= ${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN= ${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN= ${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN= ${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS= run-tui_test
+
+run-tui_test: ${PROG}
+ @td=`mktemp -d /tmp/fugu-tui-cert.XXXXXX`; \
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout $$td/key.pem -out $$td/cert.pem \
+ -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+ FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+ FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+ FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+ ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+ rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + b8a45c266a5c07ecf436de66d915c8412739f9a9 (mode 644)
--- /dev/null
+++ regress/tui/tui_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Smoke test for the curses front-end: run the real fugu under a pseudo-terminal
+ * (so isatty() selects curses), pointed at a libtls stub server. A session is
+ * pre-seeded and fugu is launched with -r, so the test also verifies that the
+ * resumed transcript ("Paris") renders before then typing "ping" and confirming
+ * the streamed reply ("pong") is shown and that fugu exits cleanly when the
+ * master side is closed.
+ */
+
+#include <sys/types.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+#include <util.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int checks, failures;
+
+/* Remove the session journals the run leaves behind, then the directory. */
+static void
+rm_sessions(const char *home)
+{
+ char path[1100], f[1200];
+ DIR *d;
+ struct dirent *e;
+
+ snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+ if ((d = opendir(path)) != NULL) {
+ while ((e = readdir(d)) != NULL) {
+ if (e->d_name[0] == '.')
+ continue;
+ snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+ unlink(f);
+ }
+ closedir(d);
+ }
+ rmdir(path);
+}
+
+#define CHECK(cond, ...) do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fprintf(stderr, "\n"); \
+ } \
+} while (0)
+
+static const char SSE_BODY[] =
+ "event: message_start\n"
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"x\","
+ "\"role\":\"assistant\",\"usage\":{\"input_tokens\":5,"
+ "\"output_tokens\":0}}}\n"
+ "\n"
+ "event: content_block_start\n"
+ "data: {\"type\":\"content_block_start\",\"index\":0,"
+ "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+ "\n"
+ "event: content_block_delta\n"
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+ "{\"type\":\"text_delta\",\"text\":\"**pong**\"}}\n"
+ "\n"
+ "event: content_block_stop\n"
+ "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+ "\n"
+ "event: message_delta\n"
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+ "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+ "\n"
+ "event: message_stop\n"
+ "data: {\"type\":\"message_stop\"}\n"
+ "\n";
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+ buf_printf(b, "%zx\r\n", n);
+ buf_append(b, data, n);
+ buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tls_write(t, p + off, len - off);
+ if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+ continue;
+ if (n < 0)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+ struct tls_config *cfg;
+ struct tls *srv, *cctx;
+ struct buf resp;
+ char req[2048];
+ ssize_t n;
+ int cfd;
+ size_t sl, half;
+
+ if ((cfd = accept(lfd, NULL, NULL)) == -1)
+ _exit(20);
+ if ((cfg = tls_config_new()) == NULL)
+ _exit(21);
+ if (tls_config_set_cert_file(cfg, cert) == -1 ||
+ tls_config_set_key_file(cfg, key) == -1)
+ _exit(22);
+ if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+ _exit(23);
+ if (tls_accept_socket(srv, &cctx, cfd) == -1)
+ _exit(24);
+ do {
+ n = tls_handshake(cctx);
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+ if (n == -1)
+ _exit(25);
+ do {
+ n = tls_read(cctx, req, sizeof(req));
+ } while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+ buf_init(&resp);
+ buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+ buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+ buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+ sl = strlen(SSE_BODY);
+ half = sl / 2;
+ add_chunk(&resp, SSE_BODY, half);
+ add_chunk(&resp, SSE_BODY + half, sl - half);
+ buf_puts_cstr(&resp, "0\r\n\r\n");
+ (void)tls_write_all(cctx, resp.data, resp.len);
+ buf_free(&resp);
+ tls_close(cctx);
+ _exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+ char dir[1024], path[1024];
+ FILE *fp;
+ int fd;
+
+ snprintf(dir, sizeof(dir), "%s/.fugu", home);
+ if (mkdir(dir, 0700) == -1)
+ err(1, "mkdir %s", dir);
+ snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+ if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+ err(1, "open %s", path);
+ if (fchmod(fd, 0600) == -1)
+ err(1, "fchmod");
+ if ((fp = fdopen(fd, "w")) == NULL)
+ err(1, "fdopen");
+ fputs("anthropic_key \"test-key\"\n", fp);
+ fputs("model \"claude-sonnet-4-6\"\n", fp);
+ fputs("max_tokens 64\n", fp);
+ fclose(fp);
+}
+
+/* Pre-seed one resumable session so the -r path has prior history to replay. */
+static void
+write_session(const char *home, const char *id)
+{
+ char dir[1024], path[1100];
+ FILE *fp;
+
+ snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+ if (mkdir(dir, 0700) == -1)
+ err(1, "mkdir %s", dir);
+ snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id);
+ if ((fp = fopen(path, "w")) == NULL)
+ err(1, "open %s", path);
+ fputs("{\"role\":\"user\",\"content\":\"capital of France\"}\n", fp);
+ fputs("{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+ "\"text\":\"Paris\"}]}\n", fp);
+ fclose(fp);
+}
+
+static void
+on_alarm(int sig)
+{
+ (void)sig;
+ dprintf(STDERR_FILENO, "tui_test: TIMED OUT\n");
+ _exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct sockaddr_in sa;
+ struct winsize ws;
+ socklen_t slen;
+ const char *fugu_bin;
+ char home[] = "/tmp/fugu-tui.XXXXXX";
+ char out[16384], portstr[16];
+ size_t outlen = 0;
+ ssize_t k;
+ pid_t sp, fp;
+ int lfd, one = 1, port, master, status, found = 0;
+
+ if (argc != 3)
+ errx(1, "usage: %s cert.pem key.pem", argv[0]);
+ if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+ errx(1, "FUGU_BIN not set");
+
+ signal(SIGALRM, on_alarm);
+ signal(SIGPIPE, SIG_IGN);
+ alarm(90);
+
+ if (mkdtemp(home) == NULL)
+ err(1, "mkdtemp");
+ write_config(home);
+ write_session(home, "55-55");
+
+ if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+ err(1, "socket");
+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ memset(&sa, 0, sizeof(sa));
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+ err(1, "bind");
+ if (listen(lfd, 1) == -1)
+ err(1, "listen");
+ slen = sizeof(sa);
+ if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+ err(1, "getsockname");
+ port = ntohs(sa.sin_port);
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ if ((sp = fork()) == -1)
+ err(1, "fork");
+ if (sp == 0) {
+ stub_server(lfd, argv[1], argv[2]);
+ _exit(0);
+ }
+ close(lfd);
+
+ memset(&ws, 0, sizeof(ws));
+ ws.ws_row = 24;
+ ws.ws_col = 80;
+ if ((fp = forkpty(&master, NULL, NULL, &ws)) == -1)
+ err(1, "forkpty");
+ if (fp == 0) {
+ setenv("HOME", home, 1);
+ setenv("TERM", "vt100", 1);
+ { /* not setgid in the harness, so FUGU_CONF is honoured */
+ char confpath[1100];
+
+ snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+ home);
+ setenv("FUGU_CONF", confpath, 1);
+ }
+ setenv("FUGU_NET_HOST", "localhost", 1);
+ setenv("FUGU_NET_PORT", portstr, 1);
+ setenv("FUGU_NET_CAFILE", argv[1], 1);
+ execl(fugu_bin, fugu_bin, "-r", "55-55", (char *)NULL);
+ _exit(127);
+ }
+
+ /* Type a message; curses reads '\r' for Return in raw mode. */
+ if (write(master, "ping\r", 5) != 5)
+ CHECK(0, "write to pty");
+
+ while (outlen < sizeof(out) - 1) {
+ k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ out[outlen] = '\0';
+ if (strstr(out, "pong") != NULL) {
+ found = 1;
+ break;
+ }
+ }
+ CHECK(found, "assistant reply 'pong' rendered to the terminal");
+ /* The curses chrome should be present too (status bar / prompt). */
+ CHECK(strstr(out, "fugu") != NULL, "transcript shows the fugu label");
+ /* The resumed session's prior assistant reply should be on screen too. */
+ CHECK(strstr(out, "Paris") != NULL,
+ "resumed transcript rendered before the new turn");
+ /* Markdown is styled, not shown literally: "**pong**" renders as "pong". */
+ CHECK(strstr(out, "**") == NULL,
+ "markdown bold markers stripped from the rendered transcript");
+ /* The editor starts in vi insert mode; the status bar says so. */
+ CHECK(strstr(out, "[INSERT]") != NULL,
+ "vi insert-mode indicator shown in the status bar");
+
+ /*
+ * Esc switches to normal mode; the status indicator changes from
+ * INSERT to NORMAL. curses only re-emits the cells that changed, so
+ * force a full repaint with Ctrl-L and match the bare word.
+ */
+ outlen = 0; /* fresh capture for the post-Esc redraw */
+ out[0] = '\0';
+ if (write(master, "\033\014", 2) != 2) /* Esc, then Ctrl-L */
+ CHECK(0, "write Esc + Ctrl-L");
+ found = 0;
+ while (outlen < sizeof(out) - 1) {
+ k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ out[outlen] = '\0';
+ if (strstr(out, "NORMAL") != NULL) {
+ found = 1;
+ break;
+ }
+ }
+ CHECK(found, "Esc switches the editor to vi normal mode");
+
+ /*
+ * A terminal resize (font-size change -> SIGWINCH) must not wedge the UI.
+ * The worker once spun forever re-reading the KEY_RESIZE that resizeterm()
+ * ungetch'd; if that regresses, the reads below never complete and the
+ * alarm fires. Resize the pty, then confirm the editor still responds:
+ * 'i' returns to insert mode and the indicator changes back.
+ */
+ ws.ws_row = 40;
+ ws.ws_col = 100;
+ if (ioctl(master, TIOCSWINSZ, &ws) == -1)
+ CHECK(0, "TIOCSWINSZ resize");
+ outlen = 0;
+ out[0] = '\0';
+ if (write(master, "i\014", 2) != 2) /* 'i' -> insert, then Ctrl-L */
+ CHECK(0, "write i + Ctrl-L after resize");
+ found = 0;
+ while (outlen < sizeof(out) - 1) {
+ k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ out[outlen] = '\0';
+ if (strstr(out, "INSERT") != NULL) {
+ found = 1;
+ break;
+ }
+ }
+ CHECK(found, "UI still responds after a terminal resize");
+
+ /*
+ * Tab completes a partial slash command. "/ex" + Tab -> "/exit"; force a
+ * repaint with Ctrl-L so the whole line is re-emitted (curses otherwise
+ * sends only changed cells). "/exit" is not in the help text, so a match
+ * means the input line actually completed.
+ */
+ outlen = 0;
+ out[0] = '\0';
+ if (write(master, "/ex\t\014", 5) != 5)
+ CHECK(0, "write /ex + Tab + Ctrl-L");
+ found = 0;
+ while (outlen < sizeof(out) - 1) {
+ k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+ if (k <= 0)
+ break;
+ outlen += (size_t)k;
+ out[outlen] = '\0';
+ if (strstr(out, "/exit") != NULL) {
+ found = 1;
+ break;
+ }
+ }
+ CHECK(found, "Tab completes /ex to /exit");
+ if (write(master, "\003", 1) != 1) /* Ctrl-C clears the input line */
+ CHECK(0, "write Ctrl-C to clear");
+
+ /* Ctrl-D on the (now empty) input line quits gracefully. */
+ if (write(master, "\004", 1) != 1)
+ CHECK(0, "write Ctrl-D");
+ while ((k = read(master, out, sizeof(out))) > 0)
+ ; /* drain until fugu closes the pty */
+ if (waitpid(fp, &status, 0) == -1)
+ err(1, "waitpid fugu");
+ CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+ "fugu exit status %d", status);
+ close(master);
+
+ kill(sp, SIGTERM);
+ waitpid(sp, &status, 0);
+
+ {
+ char p[1100];
+
+ snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+ unlink(p);
+ rm_sessions(home);
+ snprintf(p, sizeof(p), "%s/.fugu", home);
+ rmdir(p);
+ rmdir(home);
+ }
+
+ printf("tui_test: %d checks, %d failures\n", checks, failures);
+ return (failures ? 1 : 0);
+}
blob - /dev/null
blob + ac3af4b3c4e21e269bceda1c412ef96518680f06 (mode 755)
--- /dev/null
+++ run-fugu
+#!/bin/sh
+# Convenience launcher for the not-yet-installed fugu build: points the parent
+# at the freshly built worker binaries. Run from anywhere; it locates itself.
+cd "$(dirname "$0")" || exit 1
+exec env \
+ FUGU_UI_BIN="$PWD/src/ui/fugu-ui" \
+ FUGU_NET_BIN="$PWD/src/net/fugu-net" \
+ FUGU_EXEC_BIN="$PWD/src/exec/fugu-exec" \
+ FUGU_FETCH_BIN="$PWD/src/fetch/fugu-fetch" \
+ ./src/parent/fugu "$@"
blob - /dev/null
blob + 00c50ebd85a219ad8f71dab5bd6241eb4ea182b2 (mode 644)
--- /dev/null
+++ src/Makefile
+# Build each privilege-separated binary. "common" is not a program; its sources
+# are pulled into every binary via .PATH (see Makefile.inc).
+
+SUBDIR= parent ui net exec fetch
+
+.include <bsd.subdir.mk>
blob - /dev/null
blob + 3b70e4d9d0a6a935800a5748ebcd12906d8641d1 (mode 644)
--- /dev/null
+++ src/Makefile.inc
+# Shared build configuration, included by every program Makefile in src/*/.
+# Guarded so it applies exactly once even if bsd.prog.mk re-includes it.
+
+.if !defined(FUGU_MAKEFILE_INC)
+FUGU_MAKEFILE_INC=1
+
+PREFIX?= /usr/local
+
+COMMONDIR= ${.CURDIR}/../common
+
+CFLAGS+= -Wall -I${.CURDIR} -I${COMMONDIR}
+CFLAGS+= -Wstrict-prototypes -Wmissing-prototypes
+CFLAGS+= -Wmissing-declarations
+CFLAGS+= -Wshadow -Wpointer-arith -Wcast-qual
+CFLAGS+= -Wsign-compare
+
+#
+# `make FUGU_DEBUG=1` builds a hardened build for catching flakiness: UndefinedBehaviorSanitizer
+# in trap mode (no runtime needed on OpenBSD -- traps SIGILL on UB) plus libc
+# fortifications and a few extra warnings. Run the regress suite under this
+# build to surface signed overflow, alignment, null deref, format mismatch and
+# similar bugs that the default warnings miss.
+#
+# OpenBSD's clang ships without ASan/MSan runtimes; UBSan-trap and Fortify are
+# what's portable here. OpenBSD's malloc already junk-fills on alloc/free, so
+# uninitialised-read bugs (e.g. strlen past a buf's .len) manifest as garbage
+# downstream -- catch those via regress tests, not a sanitizer.
+#
+.if defined(FUGU_DEBUG)
+CFLAGS+= -g3
+CFLAGS+= -D_FORTIFY_SOURCE=2
+CFLAGS+= -fsanitize=undefined -fsanitize-trap=undefined
+CFLAGS+= -fno-omit-frame-pointer
+CFLAGS+= -Wextra -Wno-unused-parameter
+CFLAGS+= -Wformat=2 -Wno-format-nonliteral # log.c wraps printf(3)
+.endif
+
+.PATH: ${COMMONDIR}
+
+# Code shared by every process.
+SRCS+= log.c util.c buf.c json.c imsgproto.c
+
+# imsg(3) lives in libutil; every fugu process speaks imsg.
+LDADD+= -lutil
+DPADD+= ${LIBUTIL}
+
+# bsd.prog.mk does not create BINDIR; the workers install into the non-standard
+# ${PREFIX}/libexec/fugu, so create the destination before installing.
+beforeinstall:
+ ${INSTALL} -d ${DESTDIR}${BINDIR}
+
+.endif
blob - /dev/null
blob + 12a42db8540e6ec1de1f61f06a944aa35e869891 (mode 644)
--- /dev/null
+++ src/common/anthropic.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "http.h"
+#include "json.h"
+#include "provider.h"
+#include "provider_ops.h"
+
+static void ev_message_start(struct anthropic_stream *, struct json_doc *);
+static void ev_block_start(struct anthropic_stream *, struct json_doc *);
+static void ev_block_delta(struct anthropic_stream *, struct json_doc *);
+static void ev_block_stop(struct anthropic_stream *);
+static void ev_message_delta(struct anthropic_stream *, struct json_doc *);
+static void ev_error(struct anthropic_stream *, struct json_doc *);
+
+static char *obj_getstr(struct json_doc *, int, const char *);
+static long long obj_getint(struct json_doc *, int, const char *, long long);
+static const char *bufcstr(struct buf *);
+
+void
+anthropic_build_request(struct buf *out, const char *model, int max_tokens,
+ const char *system, const struct anth_msg *msgs, size_t nmsgs,
+ const char *tools_json)
+{
+ struct json_writer w;
+ size_t i;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "model", model);
+ json_kv_int(&w, "max_tokens", max_tokens);
+ json_kv_bool(&w, "stream", 1);
+ if (system != NULL)
+ json_kv_string(&w, "system", system);
+ json_key(&w, "messages");
+ json_array_start(&w);
+ for (i = 0; i < nmsgs; i++) {
+ json_object_start(&w);
+ json_kv_string(&w, "role", msgs[i].role);
+ json_kv_string(&w, "content", msgs[i].text);
+ json_object_end(&w);
+ }
+ json_array_end(&w);
+ if (tools_json != NULL) {
+ json_key(&w, "tools");
+ json_raw(&w, tools_json);
+ }
+ json_object_end(&w);
+}
+
+void
+anthropic_build_request_raw(struct buf *out, const char *model, int max_tokens,
+ const char *system, const char *messages_json, const char *tools_json)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "model", model);
+ json_kv_int(&w, "max_tokens", max_tokens);
+ json_kv_bool(&w, "stream", 1);
+ if (system != NULL)
+ json_kv_string(&w, "system", system);
+ json_key(&w, "messages");
+ json_raw(&w, messages_json);
+ if (tools_json != NULL) {
+ json_key(&w, "tools");
+ json_raw(&w, tools_json);
+ }
+ json_object_end(&w);
+}
+
+void
+anthropic_stream_init(struct anthropic_stream *s, const struct llm_cbs *cbs,
+ void *arg)
+{
+ memset(s, 0, sizeof(*s));
+ s->cbs = *cbs;
+ s->arg = arg;
+ s->cur_index = -1;
+ buf_init(&s->tool_id);
+ buf_init(&s->tool_name);
+ buf_init(&s->tool_json);
+}
+
+void
+anthropic_stream_clear(struct anthropic_stream *s)
+{
+ buf_free(&s->tool_id);
+ buf_free(&s->tool_name);
+ buf_free(&s->tool_json);
+ memset(s, 0, sizeof(*s));
+}
+
+void
+anthropic_stream_event(struct anthropic_stream *s, const char *event,
+ const char *data, size_t datalen)
+{
+ struct json_doc doc;
+
+ if (s->error)
+ return;
+ if (json_parse(&doc, data, datalen) == -1 || doc.ntok < 1 ||
+ json_tok_type(&doc, 0) != JSON_OBJECT) {
+ json_doc_free(&doc);
+ s->error = 1;
+ if (s->cbs.on_error != NULL)
+ s->cbs.on_error("parse_error", "invalid event JSON",
+ s->arg);
+ return;
+ }
+
+ if (strcmp(event, "content_block_delta") == 0)
+ ev_block_delta(s, &doc);
+ else if (strcmp(event, "content_block_start") == 0)
+ ev_block_start(s, &doc);
+ else if (strcmp(event, "content_block_stop") == 0)
+ ev_block_stop(s);
+ else if (strcmp(event, "message_start") == 0)
+ ev_message_start(s, &doc);
+ else if (strcmp(event, "message_delta") == 0)
+ ev_message_delta(s, &doc);
+ else if (strcmp(event, "message_stop") == 0) {
+ if (s->cbs.on_done != NULL)
+ s->cbs.on_done(s->arg);
+ } else if (strcmp(event, "error") == 0)
+ ev_error(s, &doc);
+ /* "ping" and any unknown event are ignored. */
+
+ json_doc_free(&doc);
+}
+
+static void
+ev_message_start(struct anthropic_stream *s, struct json_doc *d)
+{
+ int msg, usage;
+
+ if ((msg = json_obj_get(d, 0, "message")) < 0)
+ return;
+ if ((usage = json_obj_get(d, msg, "usage")) < 0)
+ return;
+ s->input_tokens = obj_getint(d, usage, "input_tokens", s->input_tokens);
+ s->output_tokens = obj_getint(d, usage, "output_tokens",
+ s->output_tokens);
+ if (s->cbs.on_usage != NULL)
+ s->cbs.on_usage(s->input_tokens, s->output_tokens, s->arg);
+}
+
+static void
+ev_block_start(struct anthropic_stream *s, struct json_doc *d)
+{
+ char *type, *id, *name;
+ size_t n;
+ int cb, id_t, name_t;
+
+ s->cur_index = (int)obj_getint(d, 0, "index", -1);
+ s->cur_is_tool = 0;
+ buf_reset(&s->tool_id);
+ buf_reset(&s->tool_name);
+ buf_reset(&s->tool_json);
+
+ if ((cb = json_obj_get(d, 0, "content_block")) < 0)
+ return;
+ if ((type = obj_getstr(d, cb, "type")) == NULL)
+ return;
+ if (strcmp(type, "tool_use") == 0) {
+ s->cur_is_tool = 1;
+ if ((id_t = json_obj_get(d, cb, "id")) >= 0 &&
+ (id = json_tok_strdup_n(d, id_t, &n)) != NULL) {
+ buf_append(&s->tool_id, id, n);
+ free(id);
+ }
+ if ((name_t = json_obj_get(d, cb, "name")) >= 0 &&
+ (name = json_tok_strdup_n(d, name_t, &n)) != NULL) {
+ buf_append(&s->tool_name, name, n);
+ free(name);
+ }
+ }
+ free(type);
+}
+
+static void
+ev_block_delta(struct anthropic_stream *s, struct json_doc *d)
+{
+ char *dtype, *frag;
+ size_t n;
+ int delta, tt;
+
+ if ((delta = json_obj_get(d, 0, "delta")) < 0)
+ return;
+ if ((dtype = obj_getstr(d, delta, "type")) == NULL)
+ return;
+
+ if (strcmp(dtype, "text_delta") == 0) {
+ if ((tt = json_obj_get(d, delta, "text")) >= 0 &&
+ (frag = json_tok_strdup_n(d, tt, &n)) != NULL) {
+ if (s->cbs.on_text != NULL)
+ s->cbs.on_text(frag, n, s->arg);
+ free(frag);
+ }
+ } else if (strcmp(dtype, "input_json_delta") == 0) {
+ if ((tt = json_obj_get(d, delta, "partial_json")) >= 0 &&
+ (frag = json_tok_strdup_n(d, tt, &n)) != NULL) {
+ buf_append(&s->tool_json, frag, n);
+ free(frag);
+ }
+ }
+ /* thinking_delta and other delta types are ignored in v1. */
+ free(dtype);
+}
+
+static void
+ev_block_stop(struct anthropic_stream *s)
+{
+ const char *id, *name, *jin;
+ size_t jlen;
+
+ if (s->cur_is_tool && s->cbs.on_tool_call != NULL) {
+ id = bufcstr(&s->tool_id);
+ name = bufcstr(&s->tool_name);
+ if (s->tool_json.len > 0) {
+ jin = bufcstr(&s->tool_json);
+ jlen = s->tool_json.len;
+ } else {
+ jin = "{}"; /* a tool call with no arguments */
+ jlen = 2;
+ }
+ s->cbs.on_tool_call(id, name, jin, jlen, s->arg);
+ }
+ s->cur_is_tool = 0;
+ s->cur_index = -1;
+ buf_reset(&s->tool_id);
+ buf_reset(&s->tool_name);
+ buf_reset(&s->tool_json);
+}
+
+static void
+ev_message_delta(struct anthropic_stream *s, struct json_doc *d)
+{
+ char *sr;
+ int delta, usage, t;
+
+ if ((usage = json_obj_get(d, 0, "usage")) >= 0) {
+ s->output_tokens = obj_getint(d, usage, "output_tokens",
+ s->output_tokens);
+ if (s->cbs.on_usage != NULL)
+ s->cbs.on_usage(s->input_tokens, s->output_tokens,
+ s->arg);
+ }
+ if ((delta = json_obj_get(d, 0, "delta")) >= 0) {
+ t = json_obj_get(d, delta, "stop_reason");
+ if (t >= 0 && json_tok_type(d, t) == JSON_STRING &&
+ (sr = json_tok_strdup(d, t)) != NULL) {
+ if (s->cbs.on_stop != NULL)
+ s->cbs.on_stop(sr, s->arg);
+ free(sr);
+ }
+ }
+}
+
+static void
+ev_error(struct anthropic_stream *s, struct json_doc *d)
+{
+ char *type = NULL, *msg = NULL;
+ int err;
+
+ if ((err = json_obj_get(d, 0, "error")) >= 0) {
+ type = obj_getstr(d, err, "type");
+ msg = obj_getstr(d, err, "message");
+ }
+ s->error = 1;
+ if (s->cbs.on_error != NULL)
+ s->cbs.on_error(type != NULL ? type : "error",
+ msg != NULL ? msg : "", s->arg);
+ free(type);
+ free(msg);
+}
+
+/* Duplicate an object member as a C string (string or primitive); NULL if absent. */
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
+
+static long long
+obj_getint(struct json_doc *d, int obj, const char *key, long long dflt)
+{
+ const char *errstr;
+ char *s;
+ long long v;
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0 || (s = json_tok_strdup(d, t)) == NULL)
+ return (dflt);
+ v = strtonum(s, LLONG_MIN, LLONG_MAX, &errstr);
+ free(s);
+ return (errstr != NULL ? dflt : v);
+}
+
+/* Borrow a buf's bytes as a NUL-terminated C string (the NUL stays uncounted). */
+static const char *
+bufcstr(struct buf *b)
+{
+ buf_terminate(b);
+ return (b->data);
+}
+
+/* ===== vtable adapters ===== */
+
+/*
+ * Default request path for the Messages API. Also the input net_run's models-
+ * endpoint derivation swaps "/messages" out of when synthesising the models
+ * list URL.
+ */
+#define ANTHROPIC_PATH "/v1/messages"
+
+/* Wire-format version the Messages API requires on every request. */
+#define ANTHROPIC_VERSION "2023-06-01"
+
+/*
+ * Build a streaming Messages request: body via anthropic_build_request_raw,
+ * plus the Anthropic-specific header set (x-api-key, anthropic-version,
+ * content-type, accept). authval is unused for Anthropic (the key rides in a
+ * dedicated header, not Authorization) but the signature is uniform across
+ * adapters; the caller still buf_freezero()s it on the way out.
+ */
+static void
+anthropic_prep_request(struct buf *body, struct http_header *h, int hmax,
+ int *nh, struct buf *authval, const char **path, const char *key,
+ const char *model, int max_tokens, const char *system, const char *conv,
+ const char *tools)
+{
+ (void)authval;
+ if (hmax < 4)
+ return; /* caller bug; net worker sizes h[4] */
+ anthropic_build_request_raw(body, model, max_tokens, system, conv,
+ tools);
+ h[0].name = "x-api-key"; h[0].value = key;
+ h[1].name = "anthropic-version"; h[1].value = ANTHROPIC_VERSION;
+ h[2].name = "content-type"; h[2].value = "application/json";
+ h[3].name = "accept"; h[3].value = "text/event-stream";
+ *nh = 4;
+ *path = ANTHROPIC_PATH;
+}
+
+static void
+anthropic_stream_init_op(union llm_stream_storage *s,
+ const struct llm_cbs *cbs, void *arg)
+{
+ anthropic_stream_init(&s->a, cbs, arg);
+}
+
+static void
+anthropic_stream_event_op(union llm_stream_storage *s, const char *event,
+ const char *data, size_t len)
+{
+ anthropic_stream_event(&s->a, event, data, len);
+}
+
+static void
+anthropic_stream_clear_op(union llm_stream_storage *s)
+{
+ anthropic_stream_clear(&s->a);
+}
+
+static const char *
+anthropic_models_path_op(void)
+{
+ return (ANTHROPIC_PATH);
+}
+
+const struct llm_provider_ops anthropic_ops = {
+ .prep_request = anthropic_prep_request,
+ .stream_init = anthropic_stream_init_op,
+ .stream_event = anthropic_stream_event_op,
+ .stream_clear = anthropic_stream_clear_op,
+ .models_path = anthropic_models_path_op,
+};
blob - /dev/null
blob + 8ca7ed4053218eb174b4c2ef8c9c63a1efb31eed (mode 644)
--- /dev/null
+++ src/common/anthropic.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * anthropic: the provider layer for the Anthropic Messages API. Two pure,
+ * socket-free halves: anthropic_build_request() serialises a streaming request
+ * body, and struct anthropic_stream interprets the Server-Sent Events of a
+ * streaming response, turning Anthropic's event vocabulary into a handful of
+ * semantic callbacks (text deltas, a completed tool call, usage, stop, done,
+ * error). The net worker drives the request over libtls and feeds the response
+ * through http -> sse -> anthropic_stream_event(); these callbacks become the
+ * imsg messages relayed to the UI and the tool workers.
+ */
+
+#ifndef FUGU_ANTHROPIC_H
+#define FUGU_ANTHROPIC_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "provider.h"
+
+/* One conversation turn, with plain-text content (v1 chat). */
+struct anth_msg {
+ const char *role; /* "user" or "assistant" */
+ const char *text; /* content as a single text block */
+};
+
+/*
+ * Serialise a streaming Messages request body into out. "stream" is always
+ * true. system is emitted only when non-NULL; tools_json, when non-NULL, is a
+ * pre-formatted JSON array spliced in as the "tools" value.
+ */
+void anthropic_build_request(struct buf *out, const char *model,
+ int max_tokens, const char *system, const struct anth_msg *msgs,
+ size_t nmsgs, const char *tools_json);
+
+/*
+ * Same, but the messages are supplied as a pre-formatted JSON array (the
+ * conversation projection the net worker receives in an M_SUBMIT), spliced in
+ * verbatim as the "messages" value.
+ */
+void anthropic_build_request_raw(struct buf *out, const char *model,
+ int max_tokens, const char *system, const char *messages_json,
+ const char *tools_json);
+
+struct anthropic_stream {
+ struct llm_cbs cbs;
+ void *arg;
+ int cur_index; /* index of the open content block */
+ int cur_is_tool; /* the open block is a tool_use */
+ long long input_tokens;
+ long long output_tokens;
+ struct buf tool_id; /* current tool_use id */
+ struct buf tool_name; /* current tool_use name */
+ struct buf tool_json; /* accumulated partial_json */
+ int error; /* a fatal stream error was seen */
+};
+
+void anthropic_stream_init(struct anthropic_stream *,
+ const struct llm_cbs *, void *);
+void anthropic_stream_clear(struct anthropic_stream *);
+
+/*
+ * Feed one SSE event (the event type and its data payload). Shaped to be
+ * called from an sse_event_cb trampoline.
+ */
+void anthropic_stream_event(struct anthropic_stream *, const char *event,
+ const char *data, size_t datalen);
+
+/*
+ * Vtable instance -- see provider.h. Defined in anthropic.c; resolved onto a
+ * struct net_provider at config-apply time so the net worker can dispatch
+ * prep_request / stream_init / stream_event / stream_clear / models_path
+ * without branching on enum llm_provider. Forward-declared here because
+ * provider.h pulls this header in before completing struct llm_provider_ops.
+ */
+struct llm_provider_ops;
+extern const struct llm_provider_ops anthropic_ops;
+
+#endif /* FUGU_ANTHROPIC_H */
blob - /dev/null
blob + 2c6b77ae367659f8940a90be3baa7930ea557ef7 (mode 644)
--- /dev/null
+++ src/common/buf.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "log.h"
+#include "util.h"
+
+#define BUF_MIN 64
+
+void
+buf_init(struct buf *b)
+{
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+void
+buf_reserve(struct buf *b, size_t need)
+{
+ size_t ncap;
+
+ if (b->cap - b->len >= need)
+ return;
+
+ ncap = (b->cap != 0) ? b->cap : BUF_MIN;
+ while (ncap - b->len < need) {
+ if (ncap > (size_t)-1 / 2)
+ fatalx("buf_reserve: capacity overflow");
+ ncap *= 2;
+ }
+ b->data = xreallocarray(b->data, ncap, 1);
+ b->cap = ncap;
+}
+
+void
+buf_append(struct buf *b, const void *data, size_t len)
+{
+ if (len == 0)
+ return;
+ buf_reserve(b, len);
+ memcpy(b->data + b->len, data, len);
+ b->len += len;
+}
+
+void
+buf_putc(struct buf *b, int c)
+{
+ unsigned char ch = (unsigned char)c;
+
+ buf_append(b, &ch, 1);
+}
+
+void
+buf_puts_cstr(struct buf *b, const char *s)
+{
+ buf_append(b, s, strlen(s));
+}
+
+/*
+ * Append a NUL byte but leave .len unchanged -- the canonical "now readable
+ * as a C string via .data" idiom in one place. Centralising it kills the
+ * scattered `buf_putc(b, '\0'); b->len--;` lines that easily drift out of
+ * sync if a future caller forgets the decrement.
+ */
+void
+buf_terminate(struct buf *b)
+{
+ buf_putc(b, '\0');
+ b->len--;
+}
+
+void
+buf_printf(struct buf *b, const char *fmt, ...)
+{
+ va_list ap, ap2;
+ int n;
+
+ va_start(ap, fmt);
+ va_copy(ap2, ap);
+ n = vsnprintf(NULL, 0, fmt, ap);
+ va_end(ap);
+ if (n < 0)
+ fatalx("buf_printf: vsnprintf");
+
+ buf_reserve(b, (size_t)n + 1);
+ n = vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ fatalx("buf_printf: vsnprintf");
+
+ /* The trailing NUL is written but deliberately not counted in len. */
+ b->len += (size_t)n;
+}
+
+void
+buf_reset(struct buf *b)
+{
+ b->len = 0;
+}
+
+void
+buf_free(struct buf *b)
+{
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+/*
+ * Like buf_free, but scrub the whole allocation first -- for buffers that have
+ * held a secret (an API key materialised into a request). freezero() handles
+ * a NULL pointer, and the size is the allocation, not the logical length.
+ */
+void
+buf_freezero(struct buf *b)
+{
+ freezero(b->data, b->cap);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
blob - /dev/null
blob + 8b55ca69fb0540cdaa85778d5060b9ae810cb3ba (mode 644)
--- /dev/null
+++ src/common/buf.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_BUF_H
+#define FUGU_BUF_H
+
+#include <sys/types.h>
+
+#include <assert.h>
+#include <stdarg.h>
+
+/*
+ * A growable byte buffer. Not implicitly NUL-terminated; callers that need a
+ * C string call buf_terminate(b) and then read .data via buf_cstr(b). The
+ * trailing NUL is NOT counted in .len. All growth goes through xreallocarray,
+ * so OOM is fatal, never silent.
+ *
+ * Naming convention is load-bearing:
+ * - buf_append(b, p, n) -- length-aware, NUL-safe
+ * - buf_puts_cstr(b, s) -- the `_cstr` suffix declares "this uses strlen,
+ * so embedded NULs in `s` truncate". Audit
+ * every call site whose bytes flow to wire or
+ * storage; prefer buf_append with a known length.
+ * - buf_terminate(b) -- the canonical "append NUL, don't count it"
+ * idiom, in one place so it can never drift.
+ * - buf_cstr(b) -- read .data as a C string with a debug assert
+ * that the trailing NUL is actually there.
+ */
+struct buf {
+ char *data;
+ size_t len;
+ size_t cap;
+};
+
+void buf_init(struct buf *);
+void buf_reserve(struct buf *, size_t);
+void buf_append(struct buf *, const void *, size_t);
+void buf_putc(struct buf *, int);
+void buf_puts_cstr(struct buf *, const char *);
+void buf_printf(struct buf *, const char *, ...)
+ __attribute__((__format__ (printf, 2, 3)));
+/* Append a '\0' but leave .len pointing at the last logical byte. */
+void buf_terminate(struct buf *);
+void buf_reset(struct buf *);
+void buf_free(struct buf *);
+void buf_freezero(struct buf *); /* zero the bytes before freeing (secrets) */
+
+/*
+ * Read .data as a C string. An empty buf returns "" so callers never need
+ * to null-check, and assert() catches the missing-terminate bug at the read
+ * site instead of the producer. Inline-in-header so the assertion line
+ * number lands at the call site under the default (non-NDEBUG) build.
+ */
+static inline const char *
+buf_cstr(const struct buf *b)
+{
+ if (b->data == NULL)
+ return ("");
+ assert(b->data[b->len] == '\0');
+ return (b->data);
+}
+
+#endif /* FUGU_BUF_H */
blob - /dev/null
blob + 7377c2ce089135786d03599b3dc08af5722c0474 (mode 644)
--- /dev/null
+++ src/common/conf.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+static void settings_merge(struct conf_settings *,
+ const struct conf_settings *, unsigned int);
+static int match_applies(const struct conf_match *, const char *,
+ char *const *, int);
+
+static void
+setstr(char **field, const char *val)
+{
+ free(*field);
+ *field = (val != NULL) ? xstrdup(val) : NULL;
+}
+
+void
+conf_settings_clear(struct conf_settings *s)
+{
+ free(s->model);
+ free(s->system);
+ free(s->provider);
+ free(s->search_provider);
+ free(s->anthropic_key);
+ free(s->kagi_token);
+ free(s->anthropic_key_file);
+ free(s->kagi_token_file);
+ free(s->api_key);
+ free(s->api_key_file);
+ free(s->api_host);
+ free(s->api_port);
+ free(s->api_path);
+ free(s->http_allow);
+ free(s->http_block);
+ memset(s, 0, sizeof(*s));
+}
+
+void
+conf_set_string(struct conf_settings *s, unsigned int flag, char **field,
+ const char *val)
+{
+ setstr(field, val);
+ s->set |= flag;
+}
+
+void
+conf_set_flag(struct conf_settings *s, unsigned int flag)
+{
+ s->set |= flag;
+}
+
+void
+conf_init(struct conf *c)
+{
+ memset(&c->global, 0, sizeof(c->global));
+ TAILQ_INIT(&c->matches);
+ TAILQ_INIT(&c->providers);
+}
+
+struct conf_match *
+conf_match_new(struct conf *c, enum match_kind kind, const char *name)
+{
+ struct conf_match *m;
+
+ m = xcalloc(1, sizeof(*m));
+ m->kind = kind;
+ m->name = xstrdup(name);
+ TAILQ_INSERT_TAIL(&c->matches, m, entry);
+ return (m);
+}
+
+struct conf_provider *
+conf_provider_new(struct conf *c, const char *name)
+{
+ struct conf_provider *p;
+
+ p = xcalloc(1, sizeof(*p));
+ p->name = xstrdup(name);
+ TAILQ_INSERT_TAIL(&c->providers, p, entry);
+ return (p);
+}
+
+void
+conf_provider_copy(struct conf_providers *out, const struct conf_provider *src)
+{
+ struct conf_provider *p;
+
+ /* First definition of a name wins: skip a later duplicate. */
+ TAILQ_FOREACH(p, out, entry)
+ if (strcmp(p->name, src->name) == 0)
+ return;
+ p = xcalloc(1, sizeof(*p));
+ p->name = xstrdup(src->name);
+ settings_merge(&p->settings, &src->settings, ~0u);
+ TAILQ_INSERT_TAIL(out, p, entry);
+}
+
+void
+conf_providers_clear(struct conf_providers *provs)
+{
+ struct conf_provider *p;
+
+ while ((p = TAILQ_FIRST(provs)) != NULL) {
+ TAILQ_REMOVE(provs, p, entry);
+ conf_settings_clear(&p->settings);
+ free(p->name);
+ free(p);
+ }
+}
+
+void
+conf_clear(struct conf *c)
+{
+ struct conf_match *m;
+
+ while ((m = TAILQ_FIRST(&c->matches)) != NULL) {
+ TAILQ_REMOVE(&c->matches, m, entry);
+ conf_settings_clear(&m->settings);
+ free(m->name);
+ free(m);
+ }
+ conf_providers_clear(&c->providers);
+ conf_settings_clear(&c->global);
+}
+
+/* Copy fields present in src and allowed by mask into dst. */
+static void
+settings_merge(struct conf_settings *d, const struct conf_settings *s,
+ unsigned int allow)
+{
+ unsigned int a = s->set & allow;
+
+ if (a & CS_MODEL)
+ setstr(&d->model, s->model);
+ if (a & CS_SYSTEM)
+ setstr(&d->system, s->system);
+ if (a & CS_MAX_TOKENS)
+ d->max_tokens = s->max_tokens;
+ if (a & CS_CONTEXT_LIMIT)
+ d->context_limit = s->context_limit;
+ if (a & CS_ALLOW_SUBPROC_NET)
+ d->allow_subprocess_net = s->allow_subprocess_net;
+ if (a & CS_ALLOW_WRITE)
+ d->allow_write = s->allow_write;
+ if (a & CS_WEB_SEARCH)
+ d->web_search = s->web_search;
+ if (a & CS_ASCII)
+ d->ascii = s->ascii;
+ if (a & CS_PROVIDER)
+ setstr(&d->provider, s->provider);
+ if (a & CS_SEARCH_PROVIDER)
+ setstr(&d->search_provider, s->search_provider);
+ if (a & CS_ANTHROPIC_KEY)
+ setstr(&d->anthropic_key, s->anthropic_key);
+ if (a & CS_KAGI_TOKEN)
+ setstr(&d->kagi_token, s->kagi_token);
+ if (a & CS_ANTHROPIC_KEY_FILE)
+ setstr(&d->anthropic_key_file, s->anthropic_key_file);
+ if (a & CS_KAGI_TOKEN_FILE)
+ setstr(&d->kagi_token_file, s->kagi_token_file);
+ if (a & CS_API_KEY)
+ setstr(&d->api_key, s->api_key);
+ if (a & CS_API_KEY_FILE)
+ setstr(&d->api_key_file, s->api_key_file);
+ if (a & CS_API_HOST)
+ setstr(&d->api_host, s->api_host);
+ if (a & CS_API_PORT)
+ setstr(&d->api_port, s->api_port);
+ if (a & CS_API_PATH)
+ setstr(&d->api_path, s->api_path);
+ if (a & CS_HTTP_ALLOW)
+ setstr(&d->http_allow, s->http_allow);
+ if (a & CS_HTTP_BLOCK)
+ setstr(&d->http_block, s->http_block);
+ d->set |= a;
+}
+
+static int
+match_applies(const struct conf_match *m, const char *user,
+ char *const *groups, int ngroups)
+{
+ int i;
+
+ if (m->kind == MATCH_USER)
+ return (user != NULL && strcmp(m->name, user) == 0);
+
+ for (i = 0; i < ngroups; i++)
+ if (strcmp(m->name, groups[i]) == 0)
+ return (1);
+ return (0);
+}
+
+void
+conf_resolve(const struct conf *c, const char *user, char *const *groups,
+ int ngroups, struct conf_settings *out)
+{
+ const struct conf_match *m;
+ unsigned int locked;
+
+ conf_settings_clear(out);
+
+ /* Global defaults form the base layer. */
+ settings_merge(out, &c->global, ~0u);
+
+ /* Match blocks override, first matching block winning per key. */
+ locked = 0;
+ TAILQ_FOREACH(m, &c->matches, entry) {
+ unsigned int apply;
+
+ if (!match_applies(m, user, groups, ngroups))
+ continue;
+ apply = m->settings.set & ~locked;
+ settings_merge(out, &m->settings, apply);
+ locked |= apply;
+ }
+}
+
+int
+conf_check_secrecy(int fd, const char *path, uid_t uid)
+{
+ struct stat st;
+
+ /*
+ * Stat the open descriptor (not the path) so the file we check is the
+ * one we read -- closing the TOCTOU window, as base check_file_secrecy
+ * does (usr.sbin/httpd/parse.y). The config holds secrets, so: it must be
+ * owned by root (the admin) or by the invoking user; it must never be
+ * world-accessible; and it must not be writable by its group or the world.
+ * Group READ is allowed -- that is exactly how the parent, running setgid
+ * _fugu, reads a root:_fugu 0640 /etc/fugu.conf.
+ */
+ if (fstat(fd, &st) == -1) {
+ log_warn("%s", path);
+ return (-1);
+ }
+ if (st.st_uid != 0 && st.st_uid != uid) {
+ log_warnx("%s: bad ownership (uid %u); must be owned by root or "
+ "you; refusing to read secrets", path, (unsigned)st.st_uid);
+ return (-1);
+ }
+ if (st.st_mode & (S_IRWXO | S_IWGRP)) {
+ log_warnx("%s: permissions %04o too open; must not be "
+ "world-accessible or group-writable", path,
+ (unsigned)(st.st_mode & 07777));
+ return (-1);
+ }
+ return (0);
+}
+
+void
+conf_settings_print(const struct conf_settings *s, FILE *fp)
+{
+ if (s->set & CS_MODEL)
+ fprintf(fp, "model \"%s\"\n", s->model);
+ if (s->set & CS_SYSTEM)
+ fprintf(fp, "system \"%s\"\n", s->system);
+ if (s->set & CS_MAX_TOKENS)
+ fprintf(fp, "max_tokens %lld\n", s->max_tokens);
+ if (s->set & CS_CONTEXT_LIMIT)
+ fprintf(fp, "context_limit %lld\n", s->context_limit);
+ if (s->set & CS_ALLOW_SUBPROC_NET)
+ fprintf(fp, "allow_subprocess_net %s\n",
+ s->allow_subprocess_net ? "yes" : "no");
+ if (s->set & CS_ALLOW_WRITE)
+ fprintf(fp, "allow_write %s\n", s->allow_write ? "yes" : "no");
+ if (s->set & CS_WEB_SEARCH)
+ fprintf(fp, "web_search %s\n", s->web_search ? "yes" : "no");
+ if (s->set & CS_ASCII)
+ fprintf(fp, "ascii %s\n", s->ascii ? "yes" : "no");
+ if (s->set & CS_PROVIDER)
+ fprintf(fp, "provider \"%s\"\n", s->provider);
+ if (s->set & CS_SEARCH_PROVIDER)
+ fprintf(fp, "search_provider \"%s\"\n", s->search_provider);
+ if (s->set & CS_API_HOST)
+ fprintf(fp, "api_host \"%s\"\n", s->api_host);
+ if (s->set & CS_API_PORT)
+ fprintf(fp, "api_port \"%s\"\n", s->api_port);
+ if (s->set & CS_API_PATH)
+ fprintf(fp, "api_path \"%s\"\n", s->api_path);
+ if (s->set & CS_HTTP_ALLOW)
+ fprintf(fp, "http_allow \"%s\"\n", s->http_allow);
+ if (s->set & CS_HTTP_BLOCK)
+ fprintf(fp, "http_block \"%s\"\n", s->http_block);
+ /* Secrets are never printed verbatim. */
+ if (s->set & CS_ANTHROPIC_KEY)
+ fprintf(fp, "anthropic_key \"***\"\n");
+ if (s->set & CS_KAGI_TOKEN)
+ fprintf(fp, "kagi_token \"***\"\n");
+ if (s->set & CS_ANTHROPIC_KEY_FILE)
+ fprintf(fp, "anthropic_key_file \"%s\"\n",
+ s->anthropic_key_file);
+ if (s->set & CS_KAGI_TOKEN_FILE)
+ fprintf(fp, "kagi_token_file \"%s\"\n", s->kagi_token_file);
+ if (s->set & CS_API_KEY)
+ fprintf(fp, "api_key \"***\"\n");
+ if (s->set & CS_API_KEY_FILE)
+ fprintf(fp, "api_key_file \"%s\"\n", s->api_key_file);
+}
+
+void
+conf_providers_print(const struct conf_providers *provs, FILE *fp)
+{
+ const struct conf_provider *p;
+
+ TAILQ_FOREACH(p, provs, entry) {
+ fprintf(fp, "provider \"%s\" {\n", p->name);
+ conf_settings_print(&p->settings, fp);
+ fprintf(fp, "}\n");
+ }
+}
blob - /dev/null
blob + a0641682800b8d8de6e48bda80eb52960c954b19 (mode 644)
--- /dev/null
+++ src/common/conf.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_CONF_H
+#define FUGU_CONF_H
+
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <stdio.h>
+
+/*
+ * The fugu configuration model. A parsed config holds global settings plus an
+ * ordered list of "match" scope blocks; conf_resolve() collapses them into the
+ * effective settings for a particular user. Each settings struct carries a
+ * bitmask recording which fields were explicitly set, so merges only touch what
+ * was actually given.
+ */
+
+#define CS_MODEL (1u << 0)
+#define CS_MAX_TOKENS (1u << 1)
+#define CS_CONTEXT_LIMIT (1u << 2)
+#define CS_ALLOW_SUBPROC_NET (1u << 3)
+#define CS_ALLOW_WRITE (1u << 4)
+#define CS_WEB_SEARCH (1u << 5)
+#define CS_SEARCH_PROVIDER (1u << 6)
+#define CS_ANTHROPIC_KEY (1u << 7)
+#define CS_KAGI_TOKEN (1u << 8)
+#define CS_ANTHROPIC_KEY_FILE (1u << 9)
+#define CS_KAGI_TOKEN_FILE (1u << 10)
+#define CS_ASCII (1u << 11)
+#define CS_SYSTEM (1u << 12)
+#define CS_PROVIDER (1u << 13)
+#define CS_API_KEY (1u << 14)
+#define CS_API_KEY_FILE (1u << 15)
+#define CS_API_HOST (1u << 16)
+#define CS_API_PORT (1u << 17)
+#define CS_API_PATH (1u << 18)
+#define CS_HTTP_ALLOW (1u << 19)
+#define CS_HTTP_BLOCK (1u << 20)
+
+#define CS_SECRETS (CS_ANTHROPIC_KEY | CS_KAGI_TOKEN | \
+ CS_ANTHROPIC_KEY_FILE | CS_KAGI_TOKEN_FILE | \
+ CS_API_KEY | CS_API_KEY_FILE)
+
+struct conf_settings {
+ unsigned int set; /* bitmask of CS_* present */
+ char *model;
+ char *system; /* system prompt sent to the model */
+ long long max_tokens;
+ long long context_limit;
+ int allow_subprocess_net;
+ int allow_write;
+ int web_search;
+ int ascii; /* render the TUI as ASCII only */
+ char *provider; /* "anthropic" (default) or "openai" */
+ char *search_provider;
+ char *anthropic_key;
+ char *kagi_token;
+ char *anthropic_key_file;
+ char *kagi_token_file;
+ char *api_key; /* generic model API key (any provider) */
+ char *api_key_file;
+ char *api_host; /* model endpoint host[:port] override */
+ char *api_port;
+ char *api_path; /* request path override (e.g. /api/v1/...) */
+ char *http_allow; /* http_request tool: allowed host patterns */
+ char *http_block; /* http_request tool: blocked host patterns */
+};
+
+enum match_kind { MATCH_USER, MATCH_GROUP };
+
+struct conf_match {
+ TAILQ_ENTRY(conf_match) entry;
+ enum match_kind kind;
+ char *name;
+ struct conf_settings settings;
+};
+TAILQ_HEAD(conf_matches, conf_match);
+
+/*
+ * A named model provider (a `provider "name" { ... }` block). Its settings
+ * reuse conf_settings, but only the model-endpoint fields are meaningful:
+ * provider (the wire type, set via the `type` keyword), model, api_key,
+ * api_key_file, api_host, api_port, api_path. The flat top-level/match keys
+ * still define an implicit "default" provider, so single-provider configs are
+ * unchanged; named blocks add more.
+ */
+struct conf_provider {
+ TAILQ_ENTRY(conf_provider) entry;
+ char *name;
+ struct conf_settings settings;
+};
+TAILQ_HEAD(conf_providers, conf_provider);
+
+struct conf {
+ struct conf_settings global;
+ struct conf_matches matches;
+ struct conf_providers providers;
+};
+
+void conf_init(struct conf *);
+void conf_clear(struct conf *);
+struct conf_match *conf_match_new(struct conf *, enum match_kind, const char *);
+struct conf_provider *conf_provider_new(struct conf *, const char *);
+/* Append a deep copy of src to out unless a provider of that name is present. */
+void conf_provider_copy(struct conf_providers *out,
+ const struct conf_provider *src);
+void conf_providers_clear(struct conf_providers *);
+
+void conf_settings_clear(struct conf_settings *);
+
+/* Set a string field, free any previous value, and record its CS_* bit. */
+void conf_set_string(struct conf_settings *, unsigned int, char **,
+ const char *);
+/* Set a scalar field's CS_* bit (the field itself is assigned by the caller). */
+void conf_set_flag(struct conf_settings *, unsigned int);
+
+/* Effective settings for a user (sshd Match-style: first matching block wins). */
+void conf_resolve(const struct conf *, const char *user,
+ char *const *groups, int ngroups, struct conf_settings *out);
+
+/*
+ * StrictModes for the open secrets file (fd): owned by root or uid, not
+ * world-accessible, not group-writable (group read is allowed, for the setgid
+ * _fugu group). Checks the descriptor, not the path, so it is TOCTOU-safe.
+ * 0 ok, -1 bad.
+ */
+int conf_check_secrecy(int fd, const char *path, uid_t uid);
+
+/* Print effective config for `fugu -n`, redacting secret values. */
+void conf_settings_print(const struct conf_settings *, FILE *);
+/* Print the named provider blocks for `fugu -n`, redacting secret values. */
+void conf_providers_print(const struct conf_providers *, FILE *);
+
+/*
+ * Parser entry point (defined in parse.y); appends to *c. When secret is
+ * nonzero the file (and any it includes) is StrictModes-checked. 0 ok, -1.
+ */
+int conf_parse_file(struct conf *, const char *path, int secret);
+
+/*
+ * Resolve the effective config for the invoking user (parent only). The named
+ * provider blocks are appended to *provs, which the caller inits and owns.
+ */
+int conf_load(struct conf_settings *, struct conf_providers *provs);
+
+#endif /* FUGU_CONF_H */
blob - /dev/null
blob + 0c82c1237e7f7cc10e7b479a27c7fec2ed45ec7d (mode 644)
--- /dev/null
+++ src/common/confload.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * conf_load: resolve the effective configuration for the invoking user from the
+ * single system file (/etc/fugu.conf), which holds the secrets and is
+ * StrictModes-checked. A normal install runs the parent setgid _fugu so it can
+ * read that root-owned file; the model's tools, which run as the user without
+ * that group, cannot. This is the parent's view; resolved values are delegated
+ * to the workers over imsg. FUGU_CONF overrides the path only when the binary
+ * is NOT setgid (issetugid()) -- i.e. for development and the regress harness,
+ * which run with no extra privilege -- so an installed fugu cannot be coaxed
+ * into reading another _fugu-readable file.
+ */
+
+#include <sys/types.h>
+
+#include <fcntl.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+#define SYS_CONF "/etc/fugu.conf"
+
+int
+conf_load(struct conf_settings *out, struct conf_providers *provs)
+{
+ struct passwd *pw;
+ struct conf c;
+ struct conf_provider *p;
+ const char *path = SYS_CONF, *env, *user;
+ gid_t *gids;
+ char **gnames;
+ int ngroups, ngn, i, rc = 0;
+
+ if ((pw = getpwuid(getuid())) == NULL) {
+ log_warnx("cannot determine current user");
+ return (-1);
+ }
+ user = pw->pw_name;
+
+ /* Resolve the user's supplementary groups to names for Match group. */
+ ngroups = 64;
+ gids = xreallocarray(NULL, ngroups, sizeof(*gids));
+ if (getgrouplist(user, pw->pw_gid, gids, &ngroups) == -1) {
+ gids = xreallocarray(gids, ngroups, sizeof(*gids));
+ if (getgrouplist(user, pw->pw_gid, gids, &ngroups) == -1)
+ ngroups = 0;
+ }
+ gnames = (ngroups > 0) ?
+ xreallocarray(NULL, ngroups, sizeof(*gnames)) : NULL;
+ ngn = 0;
+ for (i = 0; i < ngroups; i++) {
+ struct group *gr = getgrgid(gids[i]);
+
+ if (gr != NULL)
+ gnames[ngn++] = xstrdup(gr->gr_name);
+ }
+ free(gids);
+
+ if (!issetugid() && (env = getenv("FUGU_CONF")) != NULL && *env != '\0')
+ path = env;
+
+ /*
+ * The file is optional. Gate on effective-id readability (faccessat with
+ * AT_EACCESS, since we read it via the setgid group, not the real gid);
+ * absent or unreadable means no config, hence no key -- a friendly error
+ * at turn time. The open file is then StrictModes-checked in pushfile.
+ */
+ conf_init(&c);
+ if (faccessat(AT_FDCWD, path, R_OK, AT_EACCESS) == 0) {
+ if (conf_parse_file(&c, path, 1) == -1) {
+ rc = -1;
+ goto done;
+ }
+ }
+ conf_resolve(&c, user, gnames, ngn, out);
+ TAILQ_FOREACH(p, &c.providers, entry)
+ conf_provider_copy(provs, p);
+
+done:
+ conf_clear(&c);
+ for (i = 0; i < ngn; i++)
+ free(gnames[i]);
+ free(gnames);
+ return (rc);
+}
blob - /dev/null
blob + da00000cf7a3d7e9d630cfe7d3e9d341933e54cc (mode 644)
--- /dev/null
+++ src/common/fuzzy.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <ctype.h>
+#include <stddef.h>
+
+#include "fuzzy.h"
+
+/* A word boundary in an id like "anthropic/claude-3.5" or "gpt-4o_mini". */
+static int
+is_sep(int c)
+{
+ return (c == '-' || c == '/' || c == '_' || c == '.' || c == ' ' ||
+ c == ':' || c == '\t');
+}
+
+int
+fuzzy_match(const char *needle, const char *haystack, int *score)
+{
+ const char *h = haystack;
+ const char *n;
+ int s = 0;
+ int consec = 0; /* the previous needle char also matched here */
+ int prev_sep = 1; /* char before h is a boundary (start counts) */
+
+ for (n = needle; *n != '\0'; n++) {
+ int nc = tolower((unsigned char)*n);
+ int matched = 0;
+
+ while (*h != '\0') {
+ int hc = tolower((unsigned char)*h);
+ int sep = is_sep((unsigned char)*h);
+
+ if (hc == nc) {
+ s += 1;
+ if (consec)
+ s += 3; /* run of adjacent matches */
+ if (prev_sep)
+ s += 5; /* aligned to a boundary */
+ consec = 1;
+ prev_sep = sep;
+ h++;
+ matched = 1;
+ break;
+ }
+ consec = 0;
+ prev_sep = sep;
+ h++;
+ }
+ if (!matched) { /* haystack exhausted mid-needle */
+ if (score != NULL)
+ *score = 0;
+ return (0);
+ }
+ }
+
+ if (score != NULL)
+ *score = s;
+ return (1);
+}
blob - /dev/null
blob + 6340b5253e27e8ca18570684ca691be0bf802aad (mode 644)
--- /dev/null
+++ src/common/fuzzy.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_FUZZY_H
+#define FUGU_FUZZY_H
+
+/*
+ * fuzzy_match: case-insensitive subsequence match of needle within haystack.
+ * Returns 1 if every character of needle appears, in order, in haystack (an
+ * empty needle always matches), 0 otherwise. When it returns 1 and score is
+ * non-NULL, *score is set to a relevance score (higher is better): consecutive
+ * characters and matches at word boundaries (start, or after - / _ . space)
+ * score higher, so the ranking favours prefix- and segment-aligned hits.
+ */
+int fuzzy_match(const char *needle, const char *haystack, int *score);
+
+#endif /* FUGU_FUZZY_H */
blob - /dev/null
blob + 49eac1f9aaef15ffc2ffc308c0c4c1767bafb2c9 (mode 644)
--- /dev/null
+++ src/common/html2text.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <ctype.h>
+#include <string.h>
+#include <strings.h>
+
+#include "buf.h"
+#include "html2text.h"
+
+/*
+ * Whitespace is deferred so trailing spaces and runs of blank lines never reach
+ * the output: emit_space()/emit_break() record an intent, and the next visible
+ * byte flushes the strongest pending whitespace (a break beats a space, two
+ * breaks cap a paragraph gap).
+ *
+ * Invariant: out->data[0..out->len) is always well-formed UTF-8. The
+ * HTML2TEXT_MAXOUT cap is enforced atomically per codepoint by emit_codepoint,
+ * so a multi-byte sequence is never truncated between its lead and continuation
+ * bytes.
+ */
+struct emit {
+ struct buf *out;
+ int want_space;
+ int want_break; /* pending newlines, capped at 2 */
+ int started; /* any visible byte emitted yet */
+};
+
+static void
+emit_space(struct emit *e)
+{
+ if (e->started)
+ e->want_space = 1;
+}
+
+static void
+emit_break(struct emit *e, int paragraph)
+{
+ int n = paragraph ? 2 : 1;
+
+ if (!e->started)
+ return;
+ if (n > e->want_break)
+ e->want_break = n;
+}
+
+/*
+ * Atomically write a run of `n` bytes plus any pending whitespace, or drop the
+ * whole run if it would exceed HTML2TEXT_MAXOUT. Callers must pass complete
+ * UTF-8 sequences so the output buffer stays well-formed (see struct emit).
+ */
+static void
+emit_bytes(struct emit *e, const unsigned char *bytes, size_t n)
+{
+ size_t ws;
+ size_t i;
+
+ ws = (e->want_break > 0) ? (size_t)e->want_break :
+ (e->want_space ? 1 : 0);
+ if (e->out->len + ws + n > HTML2TEXT_MAXOUT)
+ return;
+ if (e->want_break > 0) {
+ while (e->want_break-- > 0)
+ buf_putc(e->out, '\n');
+ e->want_break = 0;
+ } else if (e->want_space)
+ buf_putc(e->out, ' ');
+ e->want_space = 0;
+ for (i = 0; i < n; i++)
+ buf_putc(e->out, (char)bytes[i]);
+ e->started = 1;
+}
+
+static void
+emit_byte(struct emit *e, unsigned char c)
+{
+ emit_bytes(e, &c, 1);
+}
+
+static void
+emit_codepoint(struct emit *e, long cp)
+{
+ unsigned char seq[4];
+ size_t need;
+
+ if (cp == 9 || cp == 10 || cp == 13 || cp == 32 || cp == 160) {
+ emit_space(e);
+ return;
+ }
+ if (cp < 32 || cp == 127)
+ return; /* drop other controls */
+ if ((cp >= 0xd800 && cp <= 0xdfff) || cp > 0x10ffff)
+ return; /* surrogates / out of range: not UTF-8 */
+ if (cp < 0x80) {
+ seq[0] = (unsigned char)cp;
+ need = 1;
+ } else if (cp < 0x800) {
+ seq[0] = (unsigned char)(0xc0 | (cp >> 6));
+ seq[1] = (unsigned char)(0x80 | (cp & 0x3f));
+ need = 2;
+ } else if (cp < 0x10000) {
+ seq[0] = (unsigned char)(0xe0 | (cp >> 12));
+ seq[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3f));
+ seq[2] = (unsigned char)(0x80 | (cp & 0x3f));
+ need = 3;
+ } else {
+ seq[0] = (unsigned char)(0xf0 | (cp >> 18));
+ seq[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3f));
+ seq[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3f));
+ seq[3] = (unsigned char)(0x80 | (cp & 0x3f));
+ need = 4;
+ }
+ /*
+ * Atomic boundary check: if the whole sequence (plus any pending
+ * whitespace flush) doesn't fit under the cap, drop the whole codepoint
+ * so the buffer never ends mid-sequence.
+ */
+ emit_bytes(e, seq, need);
+}
+
+/* A named character reference; emit it, or emit "&name;" literally on no match. */
+static void
+emit_named(struct emit *e, const char *name, size_t n)
+{
+ static const struct {
+ const char *name;
+ const char *rep;
+ } ents[] = {
+ { "amp", "&" }, { "lt", "<" }, { "gt", ">" }, { "quot", "\"" },
+ { "apos", "'" }, { "nbsp", " " }, { "copy", "(c)" },
+ { "reg", "(r)" }, { "trade", "(tm)" }, { "mdash", "--" },
+ { "ndash", "-" }, { "hellip", "..." }, { "lsquo", "'" },
+ { "rsquo", "'" }, { "ldquo", "\"" }, { "rdquo", "\"" },
+ { "middot", "*" },
+ };
+ size_t i;
+ const char *r;
+
+ for (i = 0; i < sizeof(ents) / sizeof(ents[0]); i++) {
+ if (strlen(ents[i].name) == n &&
+ strncasecmp(name, ents[i].name, n) == 0) {
+ size_t need = strlen(ents[i].rep);
+
+ /*
+ * Atomic boundary check: if the full replacement plus
+ * any pending whitespace flush would exceed the cap,
+ * drop the whole replacement so the output never ends
+ * in a fragment like ".." from a truncated "...".
+ */
+ if (e->out->len + (size_t)e->want_break +
+ (size_t)e->want_space + need > HTML2TEXT_MAXOUT)
+ return;
+ for (r = ents[i].rep; *r != '\0'; r++) {
+ if (*r == ' ')
+ emit_space(e);
+ else
+ emit_byte(e, (unsigned char)*r);
+ }
+ return;
+ }
+ }
+ /*
+ * Unknown name: keep the literal "&name;". Same atomic gate as the
+ * known-entity branch -- if the whole token won't fit, drop it all
+ * rather than write a truncated fragment.
+ */
+ if (e->out->len + (size_t)e->want_break + (size_t)e->want_space +
+ n + 2 > HTML2TEXT_MAXOUT)
+ return;
+ emit_byte(e, '&');
+ for (i = 0; i < n; i++)
+ emit_byte(e, (unsigned char)name[i]);
+ emit_byte(e, ';');
+}
+
+/* Decode the reference starting at html[i]=='&'; return the index past it. */
+static size_t
+decode_entity(const char *html, size_t len, size_t i, struct emit *e)
+{
+ size_t j = i + 1, s;
+
+ if (j < len && html[j] == '#') {
+ unsigned long cp = 0;
+ int hexmode = 0, digits = 0;
+
+ j++;
+ if (j < len && (html[j] == 'x' || html[j] == 'X')) {
+ hexmode = 1;
+ j++;
+ }
+ s = j;
+ while (j < len && digits < 8) {
+ unsigned char d = (unsigned char)html[j];
+
+ if (hexmode && isxdigit(d))
+ cp = cp * 16 + (isdigit(d) ? d - '0' :
+ (tolower(d) - 'a' + 10));
+ else if (!hexmode && isdigit(d))
+ cp = cp * 10 + (d - '0');
+ else
+ break;
+ if (cp > 0x110000UL) /* clamp: avoid overflow (ILP32) */
+ cp = 0x110000UL;
+ j++;
+ digits++;
+ }
+ if (j > s && j < len && html[j] == ';') {
+ emit_codepoint(e, (long)cp);
+ return (j + 1);
+ }
+ emit_byte(e, '&');
+ return (i + 1);
+ }
+ s = j;
+ while (j < len && isalnum((unsigned char)html[j]) && j - s < 12)
+ j++;
+ if (j > s && j < len && html[j] == ';') {
+ emit_named(e, html + s, j - s);
+ return (j + 1);
+ }
+ emit_byte(e, '&');
+ return (i + 1);
+}
+
+static int
+tag_is(const char *p, size_t n, const char *name)
+{
+ return (strlen(name) == n && strncasecmp(p, name, n) == 0);
+}
+
+/* Tags after which a line break reads naturally. */
+static int
+is_break_tag(const char *p, size_t n)
+{
+ static const char *const tags[] = {
+ "p", "br", "div", "li", "ul", "ol", "tr", "table", "thead",
+ "tbody", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
+ "section", "article", "header", "footer", "nav", "aside",
+ "pre", "hr", "dd", "dt", "dl", "figure", "figcaption", "form",
+ "fieldset", "address", "main", "option",
+ };
+ size_t i;
+
+ for (i = 0; i < sizeof(tags) / sizeof(tags[0]); i++)
+ if (tag_is(p, n, tags[i]))
+ return (1);
+ return (0);
+}
+
+static int
+is_paragraph_tag(const char *p, size_t n)
+{
+ return (tag_is(p, n, "p") || tag_is(p, n, "div") ||
+ tag_is(p, n, "h1") || tag_is(p, n, "h2") || tag_is(p, n, "h3") ||
+ tag_is(p, n, "h4") || tag_is(p, n, "h5") || tag_is(p, n, "h6") ||
+ tag_is(p, n, "blockquote") || tag_is(p, n, "section") ||
+ tag_is(p, n, "article") || tag_is(p, n, "pre") ||
+ tag_is(p, n, "table") || tag_is(p, n, "hr"));
+}
+
+/* Skip raw content up to the matching </name>; return the index past it. */
+static size_t
+skip_raw(const char *html, size_t len, size_t i, const char *name, size_t nlen)
+{
+ while (i < len) {
+ if (html[i] == '<' && i + 1 < len && html[i + 1] == '/' &&
+ i + 2 + nlen <= len &&
+ strncasecmp(html + i + 2, name, nlen) == 0) {
+ size_t after = i + 2 + nlen;
+
+ /*
+ * A real close tag ends the name with a delimiter; a bare
+ * prefix like </scriptx> is NOT a close, so the rest stays
+ * raw (otherwise hidden script text could leak out).
+ */
+ if (after >= len || html[after] == '>' ||
+ html[after] == '/' ||
+ isspace((unsigned char)html[after])) {
+ i = after;
+ while (i < len && html[i] != '>')
+ i++;
+ return (i < len ? i + 1 : len);
+ }
+ }
+ i++;
+ }
+ return (len);
+}
+
+void
+html2text(const char *html, size_t len, struct buf *out)
+{
+ struct emit e;
+ size_t i = 0;
+
+ memset(&e, 0, sizeof(e));
+ e.out = out;
+
+ while (i < len) {
+ unsigned char c = (unsigned char)html[i];
+
+ if (c == '<') {
+ size_t j, ns, ne, k;
+ int closing = 0;
+
+ /* Comment <!-- ... --> */
+ if (i + 3 < len && html[i + 1] == '!' &&
+ html[i + 2] == '-' && html[i + 3] == '-') {
+ i += 4;
+ while (i < len) {
+ if (i + 2 < len && html[i] == '-' &&
+ html[i + 1] == '-' &&
+ html[i + 2] == '>') {
+ i += 3;
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+ /* Declaration <!doctype ...> or PI <? ... > */
+ if (i + 1 < len &&
+ (html[i + 1] == '!' || html[i + 1] == '?')) {
+ i += 2;
+ while (i < len && html[i] != '>')
+ i++;
+ i = (i < len ? i + 1 : len);
+ continue;
+ }
+
+ j = i + 1;
+ if (j < len && html[j] == '/') {
+ closing = 1;
+ j++;
+ }
+ ns = j;
+ while (j < len && isalnum((unsigned char)html[j]))
+ j++;
+ ne = j;
+ /* Scan to '>', honouring quoted attribute values. */
+ k = j;
+ while (k < len && html[k] != '>') {
+ if (html[k] == '"' || html[k] == '\'') {
+ char q = html[k++];
+
+ while (k < len && html[k] != q)
+ k++;
+ }
+ if (k < len)
+ k++;
+ }
+ k = (k < len ? k + 1 : len);
+
+ if (ne > ns) {
+ size_t nlen = ne - ns;
+ const char *nm = html + ns;
+
+ if (!closing && (tag_is(nm, nlen, "script") ||
+ tag_is(nm, nlen, "style"))) {
+ i = skip_raw(html, len, k, nm, nlen);
+ continue;
+ }
+ if (is_break_tag(nm, nlen))
+ emit_break(&e, is_paragraph_tag(nm,
+ nlen));
+ }
+ i = k;
+ continue;
+ }
+ if (c == '&') {
+ i = decode_entity(html, len, i, &e);
+ continue;
+ }
+ if (isspace(c)) {
+ emit_space(&e);
+ i++;
+ continue;
+ }
+ emit_byte(&e, c);
+ i++;
+ }
+}
blob - /dev/null
blob + 21f8d5ab33135233a60fed1385d36aba9559a5fa (mode 644)
--- /dev/null
+++ src/common/html2text.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * html2text: a single-pass, allocation-light reduction of HTML to readable
+ * plain text. It is deliberately not a parser: it drops tags, skips
+ * script/style/comment content, decodes the common character references,
+ * inserts line breaks at block-level elements, and collapses runs of
+ * whitespace. Input is hostile web content, so the scan is strictly linear in
+ * the input length and the output is capped.
+ */
+
+#ifndef FUGU_HTML2TEXT_H
+#define FUGU_HTML2TEXT_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define HTML2TEXT_MAXOUT (256 * 1024) /* cap the produced text */
+
+/* Append the plain-text rendering of html[0..len) to out. */
+void html2text(const char *html, size_t len, struct buf *out);
+
+#endif /* FUGU_HTML2TEXT_H */
blob - /dev/null
blob + ef8ce6cec11fae1103d8dceffe173b0403fd2623 (mode 644)
--- /dev/null
+++ src/common/http.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+
+#include "buf.h"
+#include "http.h"
+
+#define HTTP_MAXLINE_DEFAULT (64U * 1024) /* status/header/chunk line cap */
+
+static int http_line(struct http_resp *, const char *, size_t);
+static int http_status(struct http_resp *, const char *, size_t);
+static int http_header(struct http_resp *, const char *, size_t);
+static int http_endhdrs(struct http_resp *);
+static int http_chunksize(struct http_resp *, const char *, size_t);
+static int ci_contains(const char *, size_t, const char *);
+
+void
+http_build_request(struct buf *out, const char *method, const char *host,
+ const char *path, const struct http_header *hdrs, size_t nhdrs,
+ const void *body, size_t bodylen)
+{
+ size_t i;
+
+ buf_printf(out, "%s %s HTTP/1.1\r\n", method, path);
+ /* Bracket an IPv6 literal in the Host header (RFC 7230 sec 5.4). */
+ if (strchr(host, ':') != NULL && host[0] != '[')
+ buf_printf(out, "Host: [%s]\r\n", host);
+ else
+ buf_printf(out, "Host: %s\r\n", host);
+ buf_puts_cstr(out, "Connection: close\r\n");
+ for (i = 0; i < nhdrs; i++)
+ buf_printf(out, "%s: %s\r\n", hdrs[i].name, hdrs[i].value);
+ if (bodylen > 0)
+ buf_printf(out, "Content-Length: %zu\r\n", bodylen);
+ buf_append(out, "\r\n", 2);
+ if (bodylen > 0)
+ buf_append(out, body, bodylen);
+}
+
+void
+http_resp_init(struct http_resp *r, http_body_cb cb, void *arg)
+{
+ memset(r, 0, sizeof(*r));
+ r->state = HTTP_STATUS;
+ r->content_length = -1;
+ r->maxline = HTTP_MAXLINE_DEFAULT;
+ buf_init(&r->line);
+ buf_init(&r->ctype);
+ r->body_cb = cb;
+ r->arg = arg;
+}
+
+void
+http_resp_clear(struct http_resp *r)
+{
+ buf_free(&r->line);
+ buf_free(&r->ctype);
+ memset(r, 0, sizeof(*r));
+}
+
+int
+http_resp_done(const struct http_resp *r)
+{
+ return (r->state == HTTP_DONE);
+}
+
+/*
+ * Feed response bytes. Returns 0, or -1 on a malformed message (bad status
+ * line, oversize line, bad chunk size). Decoded body bytes are delivered to
+ * the body callback as they are produced.
+ */
+int
+http_resp_push(struct http_resp *r, const void *vp, size_t len)
+{
+ const unsigned char *p = vp;
+ size_t i = 0, l;
+ const char *ld;
+
+ while (i < len) {
+ switch (r->state) {
+ case HTTP_STATUS:
+ case HTTP_HEADER:
+ case HTTP_CHUNK_SIZE:
+ case HTTP_TRAILER:
+ while (i < len && p[i] != '\n') {
+ if (r->line.len >= r->maxline)
+ return (-1);
+ buf_putc(&r->line, p[i++]);
+ }
+ if (i == len)
+ return (0); /* line continues next push */
+ i++; /* consume '\n' */
+ l = r->line.len;
+ ld = r->line.data;
+ if (l > 0 && ld[l - 1] == '\r')
+ l--;
+ if (http_line(r, ld, l) == -1)
+ return (-1);
+ buf_reset(&r->line);
+ break;
+ case HTTP_BODY_LEN: {
+ long long want = r->content_length - r->body_seen;
+ size_t avail = len - i, n;
+
+ n = ((long long)avail < want) ? avail : (size_t)want;
+ if (n > 0 && r->body_cb != NULL)
+ r->body_cb(p + i, n, r->arg);
+ i += n;
+ r->body_seen += n;
+ if (r->body_seen >= r->content_length)
+ r->state = HTTP_DONE;
+ break;
+ }
+ case HTTP_CHUNK_DATA: {
+ size_t avail = len - i, n;
+
+ n = ((long long)avail < r->chunk_remaining) ?
+ avail : (size_t)r->chunk_remaining;
+ if (n > 0 && r->body_cb != NULL)
+ r->body_cb(p + i, n, r->arg);
+ i += n;
+ r->chunk_remaining -= n;
+ if (r->chunk_remaining == 0)
+ r->state = HTTP_CHUNK_CRLF;
+ break;
+ }
+ case HTTP_CHUNK_CRLF:
+ /* a chunk's data must be terminated by exactly CRLF */
+ if (!r->chunk_saw_cr) {
+ if (p[i] != '\r')
+ return (-1);
+ r->chunk_saw_cr = 1;
+ i++;
+ } else {
+ if (p[i] != '\n')
+ return (-1);
+ r->chunk_saw_cr = 0;
+ i++;
+ r->state = HTTP_CHUNK_SIZE;
+ }
+ break;
+ case HTTP_BODY_EOF:
+ if (r->body_cb != NULL && len - i > 0)
+ r->body_cb(p + i, len - i, r->arg);
+ i = len;
+ break;
+ case HTTP_DONE:
+ i = len; /* ignore trailing bytes */
+ break;
+ }
+ }
+ return (0);
+}
+
+/*
+ * Signal that the connection closed. Completes an EOF-framed body; otherwise
+ * a close mid-message is a truncation error.
+ */
+int
+http_resp_eof(struct http_resp *r)
+{
+ if (r->state == HTTP_BODY_EOF) {
+ r->state = HTTP_DONE;
+ return (0);
+ }
+ return (r->state == HTTP_DONE ? 0 : -1);
+}
+
+/* Dispatch one complete line according to the current state. */
+static int
+http_line(struct http_resp *r, const char *line, size_t len)
+{
+ switch (r->state) {
+ case HTTP_STATUS:
+ if (http_status(r, line, len) == -1)
+ return (-1);
+ r->state = HTTP_HEADER;
+ return (0);
+ case HTTP_HEADER:
+ if (len == 0)
+ return (http_endhdrs(r));
+ return (http_header(r, line, len));
+ case HTTP_CHUNK_SIZE:
+ return (http_chunksize(r, line, len));
+ case HTTP_TRAILER:
+ if (len == 0)
+ r->state = HTTP_DONE;
+ return (0);
+ default:
+ return (-1);
+ }
+}
+
+static int
+http_status(struct http_resp *r, const char *line, size_t len)
+{
+ size_t i = 0;
+ int code = 0, ndig = 0;
+
+ if (len < 5 || strncmp(line, "HTTP/", 5) != 0)
+ return (-1);
+ while (i < len && line[i] != ' ') /* skip "HTTP/x.y" */
+ i++;
+ while (i < len && line[i] == ' ')
+ i++;
+ while (i < len && ndig < 3 && line[i] >= '0' && line[i] <= '9') {
+ code = code * 10 + (line[i] - '0');
+ i++;
+ ndig++;
+ }
+ if (ndig != 3)
+ return (-1);
+ r->status = code;
+ return (0);
+}
+
+static int
+http_header(struct http_resp *r, const char *line, size_t len)
+{
+ const char *val;
+ size_t nlen, vlen, i = 0;
+
+ while (i < len && line[i] != ':')
+ i++;
+ if (i == len) /* no colon: tolerate, ignore */
+ return (0);
+ nlen = i;
+ val = line + i + 1;
+ vlen = len - (i + 1);
+ while (vlen > 0 && (*val == ' ' || *val == '\t')) {
+ val++;
+ vlen--;
+ }
+ while (vlen > 0 && (val[vlen - 1] == ' ' || val[vlen - 1] == '\t'))
+ vlen--;
+
+ if (nlen == 17 && strncasecmp(line, "Transfer-Encoding", 17) == 0) {
+ if (ci_contains(val, vlen, "chunked"))
+ r->chunked = 1;
+ } else if (nlen == 14 && strncasecmp(line, "Content-Length", 14) == 0) {
+ char num[32];
+ const char *errstr;
+
+ if (vlen == 0 || vlen >= sizeof(num))
+ return (-1);
+ memcpy(num, val, vlen);
+ num[vlen] = '\0';
+ r->content_length = strtonum(num, 0, LLONG_MAX, &errstr);
+ if (errstr != NULL)
+ return (-1);
+ } else if (nlen == 12 && strncasecmp(line, "Content-Type", 12) == 0) {
+ buf_reset(&r->ctype);
+ buf_append(&r->ctype, val, vlen);
+ }
+ return (0);
+}
+
+static int
+http_endhdrs(struct http_resp *r)
+{
+ /* 1xx, 204 and 304 carry no body regardless of header framing. */
+ if (r->status == 204 || r->status == 304 ||
+ (r->status >= 100 && r->status < 200)) {
+ r->state = HTTP_DONE;
+ return (0);
+ }
+ /* Chunked takes precedence over Content-Length per RFC 7230. */
+ if (r->chunked)
+ r->state = HTTP_CHUNK_SIZE;
+ else if (r->content_length == 0)
+ r->state = HTTP_DONE;
+ else if (r->content_length > 0)
+ r->state = HTTP_BODY_LEN;
+ else
+ r->state = HTTP_BODY_EOF;
+ return (0);
+}
+
+static int
+http_chunksize(struct http_resp *r, const char *line, size_t len)
+{
+ long long size = 0;
+ size_t i;
+ int ndig = 0, d;
+
+ for (i = 0; i < len; i++) {
+ char ch = line[i];
+
+ if (ch >= '0' && ch <= '9')
+ d = ch - '0';
+ else if (ch >= 'a' && ch <= 'f')
+ d = ch - 'a' + 10;
+ else if (ch >= 'A' && ch <= 'F')
+ d = ch - 'A' + 10;
+ else
+ break; /* ';' extension or end of digits */
+ if (size > (LLONG_MAX - d) / 16)
+ return (-1); /* overflow */
+ size = size * 16 + d;
+ ndig++;
+ }
+ if (ndig == 0)
+ return (-1);
+ if (size == 0)
+ r->state = HTTP_TRAILER;
+ else {
+ r->chunk_remaining = size;
+ r->state = HTTP_CHUNK_DATA;
+ }
+ return (0);
+}
+
+/* Case-insensitive substring search over a non-NUL-terminated haystack. */
+static int
+ci_contains(const char *hay, size_t haylen, const char *needle)
+{
+ size_t nlen = strlen(needle), i;
+
+ if (nlen == 0 || haylen < nlen)
+ return (0);
+ for (i = 0; i + nlen <= haylen; i++) {
+ if (strncasecmp(hay + i, needle, nlen) == 0)
+ return (1);
+ }
+ return (0);
+}
blob - /dev/null
blob + 22201dbb2c4501ce476df0ec0a6017e8efcd3b90 (mode 644)
--- /dev/null
+++ src/common/http.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * http: a minimal HTTP/1.1 client, split into pure pieces so the protocol
+ * logic is testable without a socket. http_build_request() serialises a
+ * request into a buf; struct http_resp is an incremental parser that consumes
+ * response bytes as they arrive off the wire, tracks the status line and the
+ * headers we care about, de-frames the body (Content-Length, chunked transfer
+ * encoding, or read-until-close), and hands decoded body bytes to a callback.
+ *
+ * The libtls transport that drives this lives separately; both the Anthropic
+ * (net) and Kagi/web (fetch) paths share this code, so the response parser is
+ * written to tolerate hostile servers: every line is length-capped and the
+ * chunk-size arithmetic is overflow-checked.
+ */
+
+#ifndef FUGU_HTTP_H
+#define FUGU_HTTP_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+struct http_header {
+ const char *name;
+ const char *value;
+};
+
+/*
+ * Serialise an HTTP/1.1 request into out. Host is always emitted; so is
+ * "Connection: close" (fugu uses one request per connection) and, when
+ * bodylen > 0, Content-Length. The caller supplies any other headers (auth,
+ * content-type, accept, ...). The body, if any, is appended verbatim.
+ */
+void http_build_request(struct buf *out, const char *method,
+ const char *host, const char *path, const struct http_header *hdrs,
+ size_t nhdrs, const void *body, size_t bodylen);
+
+/* Called with successive decoded body bytes; never with len 0. */
+typedef void (*http_body_cb)(const void *data, size_t len, void *arg);
+
+enum http_state {
+ HTTP_STATUS, /* reading the status line */
+ HTTP_HEADER, /* reading header lines */
+ HTTP_BODY_LEN, /* Content-Length body */
+ HTTP_CHUNK_SIZE, /* reading a chunk-size line */
+ HTTP_CHUNK_DATA, /* copying a chunk's data */
+ HTTP_CHUNK_CRLF, /* the CRLF after a chunk's data */
+ HTTP_TRAILER, /* trailer headers after the last chunk */
+ HTTP_BODY_EOF, /* body framed by connection close */
+ HTTP_DONE /* message fully received */
+};
+
+struct http_resp {
+ enum http_state state;
+ int status; /* parsed status code */
+ int chunked; /* Transfer-Encoding: chunked seen */
+ long long content_length; /* -1 if absent */
+ long long body_seen; /* bytes delivered (length mode) */
+ long long chunk_remaining;/* bytes left in the current chunk */
+ int chunk_saw_cr; /* saw the CR of a chunk's trailing CRLF */
+ struct buf line; /* accumulates the current line */
+ struct buf ctype; /* Content-Type value */
+ size_t maxline; /* per-line cap (hostile-server guard) */
+ http_body_cb body_cb;
+ void *arg;
+};
+
+void http_resp_init(struct http_resp *, http_body_cb, void *);
+void http_resp_clear(struct http_resp *);
+int http_resp_push(struct http_resp *, const void *, size_t); /* 0 / -1 */
+int http_resp_eof(struct http_resp *); /* connection closed; 0 / -1 */
+int http_resp_done(const struct http_resp *);
+
+#endif /* FUGU_HTTP_H */
blob - /dev/null
blob + 8b46e44d0a2dec67be78600478328199e5266c84 (mode 644)
--- /dev/null
+++ src/common/imsgproto.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/uio.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <imsg.h>
+
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+static struct ip_reasm *reasm_find(struct imsgproto *, uint32_t, uint32_t);
+static struct ip_reasm *reasm_alloc(struct imsgproto *);
+static void reasm_reserve(struct ip_reasm *, size_t);
+static void reasm_release(struct ip_reasm *);
+static int handle_chunk(struct imsgproto *, struct imsg *,
+ struct fugu_msg *);
+
+int
+ip_init(struct imsgproto *ip, int fd)
+{
+ memset(ip, 0, sizeof(*ip));
+ if (imsgbuf_init(&ip->ibuf, fd) == -1)
+ return (-1);
+ return (0);
+}
+
+void
+ip_clear(struct imsgproto *ip)
+{
+ int i;
+
+ for (i = 0; i < IP_MAXASM; i++)
+ reasm_release(&ip->reasm[i]);
+ imsgbuf_clear(&ip->ibuf);
+}
+
+int
+ip_fd(struct imsgproto *ip)
+{
+ return (ip->ibuf.fd);
+}
+
+/*
+ * Frame a logical message as one or more imsg chunks and queue them for
+ * transmission. A zero-length message is sent as a single empty chunk so the
+ * far side still observes it. Returns 0 on success, -1 on failure.
+ */
+int
+ip_compose(struct imsgproto *ip, uint32_t type, uint32_t dst, uint32_t src,
+ const void *data, size_t len)
+{
+ struct fugu_chdr chdr;
+ struct iovec iov[2];
+ const uint8_t *p = data;
+ size_t off, chunklen;
+ uint32_t msgid;
+
+ if (len > IP_MAXMSG) {
+ log_warnx("ip_compose: message too large (%zu > %u)", len,
+ IP_MAXMSG);
+ return (-1);
+ }
+
+ msgid = ip->next_msgid++;
+ off = 0;
+ do {
+ chunklen = len - off;
+ if (chunklen > IP_MAXCHUNK)
+ chunklen = IP_MAXCHUNK;
+
+ memset(&chdr, 0, sizeof(chdr));
+ chdr.src = src;
+ chdr.msgid = msgid;
+ chdr.total = (uint32_t)len;
+ chdr.off = (uint32_t)off;
+
+ iov[0].iov_base = &chdr;
+ iov[0].iov_len = sizeof(chdr);
+ /* iov_base is non-const by POSIX; this buffer is only read. */
+ iov[1].iov_base = (void *)(uintptr_t)(p + off);
+ iov[1].iov_len = chunklen;
+
+ if (imsg_composev(&ip->ibuf, type, dst, 0, -1, iov, 2) == -1)
+ return (-1);
+
+ off += chunklen;
+ } while (off < len);
+
+ return (0);
+}
+
+/* Block until every queued message is written. 0 on success, -1 on error. */
+int
+ip_flush(struct imsgproto *ip)
+{
+ return (imsgbuf_flush(&ip->ibuf));
+}
+
+/* Do a single non-blocking write pass. 0 on success, -1 on error. */
+int
+ip_write(struct imsgproto *ip)
+{
+ return (imsgbuf_write(&ip->ibuf));
+}
+
+/* Read pending data into the buffer. 1 ok, 0 on EOF, -1 on error. */
+int
+ip_read(struct imsgproto *ip)
+{
+ return (imsgbuf_read(&ip->ibuf));
+}
+
+/*
+ * Pull the next fully reassembled logical message, consuming as many buffered
+ * imsg chunks as needed. Returns 1 and fills *msg on success (caller frees via
+ * ip_msg_free()), 0 if no complete message is ready, -1 on protocol error.
+ */
+int
+ip_get(struct imsgproto *ip, struct fugu_msg *msg)
+{
+ struct imsg imsg;
+ int n, rc;
+
+ for (;;) {
+ if ((n = imsgbuf_get(&ip->ibuf, &imsg)) == -1)
+ return (-1);
+ if (n == 0)
+ return (0);
+
+ rc = handle_chunk(ip, &imsg, msg);
+ imsg_free(&imsg);
+
+ if (rc != 0)
+ return (rc); /* 1 = completed, -1 = protocol error */
+ }
+}
+
+void
+ip_msg_free(struct fugu_msg *msg)
+{
+ free(msg->data);
+ memset(msg, 0, sizeof(*msg));
+}
+
+/* Pull one raw frame for the relay (no reassembly). 1 / 0 / -1. */
+int
+ip_frame_get(struct imsgproto *ip, struct imsg *imsg)
+{
+ return (imsgbuf_get(&ip->ibuf, imsg));
+}
+
+/*
+ * Relay a just-received frame to another channel, unaltered, without
+ * reassembling it. This is the parent's routing primitive: forward by
+ * imsg_get_id() and never touch the payload.
+ */
+int
+ip_forward(struct imsgproto *dst, struct imsg *imsg)
+{
+ return (imsg_forward(&dst->ibuf, imsg));
+}
+
+static struct ip_reasm *
+reasm_find(struct imsgproto *ip, uint32_t src, uint32_t msgid)
+{
+ int i;
+
+ for (i = 0; i < IP_MAXASM; i++) {
+ if (ip->reasm[i].inuse && ip->reasm[i].src == src &&
+ ip->reasm[i].msgid == msgid)
+ return (&ip->reasm[i]);
+ }
+ return (NULL);
+}
+
+static struct ip_reasm *
+reasm_alloc(struct imsgproto *ip)
+{
+ struct ip_reasm *oldest = NULL;
+ int i;
+
+ for (i = 0; i < IP_MAXASM; i++) {
+ if (!ip->reasm[i].inuse) {
+ memset(&ip->reasm[i], 0, sizeof(ip->reasm[i]));
+ ip->reasm[i].inuse = 1;
+ ip->reasm[i].seq = ip->asm_seq++;
+ return (&ip->reasm[i]);
+ }
+ if (oldest == NULL || ip->reasm[i].seq < oldest->seq)
+ oldest = &ip->reasm[i];
+ }
+
+ /*
+ * Table full: a peer is holding more partials open than we keep slots
+ * for. Evict the oldest so legitimate new traffic still makes progress
+ * instead of the channel wedging.
+ */
+ log_warnx("ip: evicting stalled reassembly (msgid %u)", oldest->msgid);
+ reasm_release(oldest);
+ oldest->inuse = 1;
+ oldest->seq = ip->asm_seq++;
+ return (oldest);
+}
+
+/* Grow a partial's buffer to hold at least need bytes, never past its total. */
+static void
+reasm_reserve(struct ip_reasm *r, size_t need)
+{
+ size_t newcap;
+
+ if (need <= r->cap)
+ return;
+ newcap = (r->cap == 0) ? need : (size_t)r->cap * 2;
+ if (newcap < need)
+ newcap = need;
+ if (newcap > r->total)
+ newcap = r->total;
+ r->data = xreallocarray(r->data, newcap, 1);
+ r->cap = (uint32_t)newcap;
+}
+
+static void
+reasm_release(struct ip_reasm *r)
+{
+ free(r->data);
+ memset(r, 0, sizeof(*r));
+}
+
+/*
+ * Fold one received imsg (a single chunk) into the reassembly state.
+ * Returns 1 if it completed a logical message (filled *msg), 0 if the chunk was
+ * consumed but the message is still incomplete, -1 on any protocol violation.
+ */
+static int
+handle_chunk(struct imsgproto *ip, struct imsg *imsg, struct fugu_msg *msg)
+{
+ struct fugu_chdr chdr;
+ struct ip_reasm *r;
+ uint32_t type, dst;
+ size_t plen, clen;
+
+ type = imsg_get_type(imsg);
+ dst = imsg_get_id(imsg);
+ plen = imsg_get_len(imsg);
+
+ if (plen < sizeof(chdr)) {
+ log_warnx("ip: short frame (%zu bytes)", plen);
+ return (-1);
+ }
+ if (imsg_get_buf(imsg, &chdr, sizeof(chdr)) == -1)
+ return (-1);
+ clen = plen - sizeof(chdr);
+
+ /* Bound the advertised total and validate this chunk against it. */
+ if (chdr.total > IP_MAXMSG) {
+ log_warnx("ip: oversize message (%u > %u)", chdr.total,
+ IP_MAXMSG);
+ return (-1);
+ }
+ if ((size_t)chdr.off + clen > (size_t)chdr.total) {
+ log_warnx("ip: chunk past end (off %u + %zu > total %u)",
+ chdr.off, clen, chdr.total);
+ return (-1);
+ }
+ if (clen == 0 && chdr.total != 0) {
+ log_warnx("ip: empty chunk in non-empty message");
+ return (-1);
+ }
+
+ r = reasm_find(ip, chdr.src, chdr.msgid);
+ if (r == NULL) {
+ /* First chunk of a logical message must start at offset 0. */
+ if (chdr.off != 0) {
+ log_warnx("ip: missing start of message %u", chdr.msgid);
+ return (-1);
+ }
+ /* Fast path: the whole message fits in this single chunk. */
+ if (clen == chdr.total) {
+ msg->type = type;
+ msg->dst = dst;
+ msg->src = chdr.src;
+ msg->len = chdr.total;
+ msg->data = NULL;
+ if (chdr.total > 0) {
+ msg->data = xmalloc(chdr.total);
+ if (imsg_get_buf(imsg, msg->data,
+ chdr.total) == -1) {
+ ip_msg_free(msg);
+ return (-1);
+ }
+ }
+ return (1);
+ }
+
+ /* Multi-chunk: take a slot; its buffer grows as chunks land. */
+ r = reasm_alloc(ip);
+ r->src = chdr.src;
+ r->msgid = chdr.msgid;
+ r->type = type;
+ r->dst = dst;
+ r->total = chdr.total;
+ }
+
+ /* Continuation: enforce strict in-order, consistent framing. */
+ if (type != r->type || dst != r->dst || chdr.total != r->total ||
+ chdr.off != r->have) {
+ log_warnx("ip: inconsistent chunk for message %u", chdr.msgid);
+ reasm_release(r);
+ return (-1);
+ }
+ /*
+ * Grow only to the bytes actually delivered (continuation is append-only
+ * since off == have), so a tiny first chunk cannot reserve a huge buffer.
+ */
+ reasm_reserve(r, (size_t)r->have + clen);
+ if (imsg_get_buf(imsg, r->data + r->have, clen) == -1) {
+ reasm_release(r);
+ return (-1);
+ }
+ r->have += (uint32_t)clen;
+
+ if (r->have < r->total)
+ return (0);
+
+ /* Complete: hand the assembled buffer to the caller. */
+ msg->type = r->type;
+ msg->dst = r->dst;
+ msg->src = r->src;
+ msg->data = r->data;
+ msg->len = r->total;
+ r->data = NULL; /* ownership transferred to msg */
+ reasm_release(r);
+ return (1);
+}
blob - /dev/null
blob + 5292ed8e90f98116b4860ae731105becd5d608d9 (mode 644)
--- /dev/null
+++ src/common/imsgproto.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * imsgproto: fugu's logical message layer over imsg(3).
+ *
+ * imsg payloads are bounded (MAX_IMSGSIZE, 16 KiB including header), but a
+ * conversation projection or a tool's output can be far larger. This layer
+ * frames a logical message of arbitrary length as one or more imsg "chunks"
+ * and reassembles them on the far side.
+ *
+ * Routing is carried in the imsg header so the parent relay never has to look
+ * at, let alone reassemble, a payload it forwards:
+ *
+ * imsg type (uint32) = logical message type (enum fugu_msgtype)
+ * imsg id (uint32) = destination endpoint (enum fugu_ep)
+ * payload = struct fugu_chdr + chunk bytes
+ *
+ * The parent forwards a frame to its destination endpoint with imsg_forward(3)
+ * purely on imsg_get_id(); reassembly happens only at the final endpoint.
+ *
+ * Wire byte order is host order: this is same-host IPC between processes of one
+ * build, the standard imsg(3) convention.
+ */
+
+#ifndef FUGU_IMSGPROTO_H
+#define FUGU_IMSGPROTO_H
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <stdint.h>
+
+#include <imsg.h>
+
+/* Logical message types (the imsg "type" field). */
+enum fugu_msgtype {
+ M_NONE = 0,
+ M_READY, /* worker -> parent : startup handshake */
+ M_CONFIG, /* parent -> worker : resolved non-secret settings */
+ M_SECRET, /* parent -> net|fetch : one credential (net: uint32 */
+ /* provider index + key bytes; fetch: raw) */
+ M_SUBMIT, /* ui -> net : conversation projection */
+ M_CANCEL, /* ui -> net : abort the in-flight turn */
+ M_DELTA, /* net -> ui : streamed assistant text */
+ M_TOOL_CALL, /* net -> ui : tool invocation notice */
+ M_MESSAGE, /* net -> ui : a complete conversation message */
+ M_TOOL_REQUEST, /* net -> exec|fetch|ui : run a tool */
+ M_TOOL_RESULT, /* exec|fetch|ui -> net : tool result */
+ M_TOOLS_ADD, /* ui -> net : extra tool defs (JSON fragment, */
+ /* comma-prefixed, no enclosing brackets) */
+ M_USAGE, /* net -> ui : token accounting */
+ M_TURN_DONE, /* net -> ui : assistant turn complete */
+ M_SUMMARIZE, /* ui -> net : summarise the conversation (/compact) */
+ M_SUMMARY, /* net -> ui : the conversation summary text */
+ M_MODELS, /* ui <-> net : request (empty) / reply (id list; with */
+ /* >1 provider each row is "name\tmodel") */
+ M_SET_MODEL, /* ui -> net : switch model ("model" or "name\tmodel") */
+ M_ERROR, /* any -> ui : human-readable error */
+ M_SHUTDOWN, /* parent -> worker : orderly exit */
+ M__MAX
+};
+
+/* Endpoint identifiers (the imsg "id" field; the relay's routing key). */
+enum fugu_ep {
+ EP_PARENT = 0,
+ EP_UI,
+ EP_NET,
+ EP_EXEC,
+ EP_FETCH,
+ EP__MAX
+};
+
+/*
+ * Per-chunk sub-header, prepended to each chunk's bytes inside the imsg
+ * payload. Sixteen bytes; the chunk's data length is the imsg payload length
+ * minus sizeof(struct fugu_chdr).
+ */
+struct fugu_chdr {
+ uint32_t src; /* source endpoint (enum fugu_ep) */
+ uint32_t msgid; /* logical id, unique per source channel */
+ uint32_t total; /* total logical payload length */
+ uint32_t off; /* byte offset of this chunk in the payload */
+};
+
+/*
+ * Reassembly is bounded two ways against a hostile peer: a partial message's
+ * buffer grows with the bytes actually delivered (never eagerly to the
+ * advertised total), and when all IP_MAXASM slots are busy the oldest partial
+ * is evicted, so a flood of never-completed messages cannot wedge the channel.
+ */
+#define IP_MAXASM 8 /* concurrent reassemblies */
+#define IP_MAXMSG (16U * 1024 * 1024) /* logical message size cap */
+#define IP_MAXCHUNK \
+ (MAX_IMSGSIZE - IMSG_HEADER_SIZE - sizeof(struct fugu_chdr))
+
+/* In-progress reassembly of one logical message. */
+struct ip_reasm {
+ int inuse;
+ uint32_t src;
+ uint32_t msgid;
+ uint32_t type;
+ uint32_t dst;
+ uint32_t total; /* advertised final length */
+ uint32_t have; /* bytes received so far (== next expected off) */
+ uint32_t cap; /* allocated size of data (grows toward total) */
+ uint64_t seq; /* allocation order, for oldest-first eviction */
+ uint8_t *data;
+};
+
+/* One imsg channel plus its reassembly state. */
+struct imsgproto {
+ struct imsgbuf ibuf;
+ uint32_t next_msgid;
+ uint64_t asm_seq; /* monotonic stamp for reasm eviction */
+ struct ip_reasm reasm[IP_MAXASM];
+};
+
+/* A fully reassembled logical message handed to the caller. */
+struct fugu_msg {
+ uint32_t type; /* enum fugu_msgtype */
+ uint32_t dst; /* enum fugu_ep destination */
+ uint32_t src; /* enum fugu_ep source */
+ uint8_t *data; /* heap, freed by ip_msg_free(); NULL if len 0 */
+ size_t len;
+};
+
+int ip_init(struct imsgproto *, int);
+void ip_clear(struct imsgproto *);
+int ip_fd(struct imsgproto *);
+
+int ip_compose(struct imsgproto *, uint32_t, uint32_t, uint32_t,
+ const void *, size_t);
+int ip_flush(struct imsgproto *);
+int ip_write(struct imsgproto *);
+
+int ip_read(struct imsgproto *);
+int ip_get(struct imsgproto *, struct fugu_msg *);
+void ip_msg_free(struct fugu_msg *);
+
+/*
+ * Relay support: pull the next raw frame without reassembling it (1/0/-1, like
+ * ip_get). The caller routes on imsg_get_id(), ip_forward()s it to the
+ * destination channel, and imsg_free()s it. This is how the parent relays
+ * worker<->worker traffic without ever buffering a whole logical message.
+ */
+int ip_frame_get(struct imsgproto *, struct imsg *);
+int ip_forward(struct imsgproto *, struct imsg *);
+
+#endif /* FUGU_IMSGPROTO_H */
blob - /dev/null
blob + 8ac14c1bdec9d1600ae5217550902eecce0f56e1 (mode 644)
--- /dev/null
+++ src/common/jsmn.h
+/*
+ * MIT License
+ *
+ * Copyright (c) 2010 Serge Zaitsev
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+#ifndef JSMN_H
+#define JSMN_H
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifdef JSMN_STATIC
+#define JSMN_API static
+#else
+#define JSMN_API extern
+#endif
+
+/**
+ * JSON type identifier. Basic types are:
+ * o Object
+ * o Array
+ * o String
+ * o Other primitive: number, boolean (true/false) or null
+ */
+typedef enum {
+ JSMN_UNDEFINED = 0,
+ JSMN_OBJECT = 1 << 0,
+ JSMN_ARRAY = 1 << 1,
+ JSMN_STRING = 1 << 2,
+ JSMN_PRIMITIVE = 1 << 3
+} jsmntype_t;
+
+enum jsmnerr {
+ /* Not enough tokens were provided */
+ JSMN_ERROR_NOMEM = -1,
+ /* Invalid character inside JSON string */
+ JSMN_ERROR_INVAL = -2,
+ /* The string is not a full JSON packet, more bytes expected */
+ JSMN_ERROR_PART = -3
+};
+
+/**
+ * JSON token description.
+ * type type (object, array, string etc.)
+ * start start position in JSON data string
+ * end end position in JSON data string
+ */
+typedef struct jsmntok {
+ jsmntype_t type;
+ int start;
+ int end;
+ int size;
+#ifdef JSMN_PARENT_LINKS
+ int parent;
+#endif
+} jsmntok_t;
+
+/**
+ * JSON parser. Contains an array of token blocks available. Also stores
+ * the string being parsed now and current position in that string.
+ */
+typedef struct jsmn_parser {
+ unsigned int pos; /* offset in the JSON string */
+ unsigned int toknext; /* next token to allocate */
+ int toksuper; /* superior token node, e.g. parent object or array */
+} jsmn_parser;
+
+/**
+ * Create JSON parser over an array of tokens
+ */
+JSMN_API void jsmn_init(jsmn_parser *parser);
+
+/**
+ * Run JSON parser. It parses a JSON data string into and array of tokens, each
+ * describing
+ * a single JSON object.
+ */
+JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
+ jsmntok_t *tokens, const unsigned int num_tokens);
+
+#ifndef JSMN_HEADER
+/**
+ * Allocates a fresh unused token from the token pool.
+ */
+static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens,
+ const size_t num_tokens) {
+ jsmntok_t *tok;
+ if (parser->toknext >= num_tokens) {
+ return NULL;
+ }
+ tok = &tokens[parser->toknext++];
+ tok->start = tok->end = -1;
+ tok->size = 0;
+#ifdef JSMN_PARENT_LINKS
+ tok->parent = -1;
+#endif
+ return tok;
+}
+
+/**
+ * Fills token type and boundaries.
+ */
+static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type,
+ const int start, const int end) {
+ token->type = type;
+ token->start = start;
+ token->end = end;
+ token->size = 0;
+}
+
+/**
+ * Fills next available token with JSON primitive.
+ */
+static int jsmn_parse_primitive(jsmn_parser *parser, const char *js,
+ const size_t len, jsmntok_t *tokens,
+ const size_t num_tokens) {
+ jsmntok_t *token;
+ int start;
+
+ start = parser->pos;
+
+ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+ switch (js[parser->pos]) {
+#ifndef JSMN_STRICT
+ /* In strict mode primitive must be followed by "," or "}" or "]" */
+ case ':':
+#endif
+ case '\t':
+ case '\r':
+ case '\n':
+ case ' ':
+ case ',':
+ case ']':
+ case '}':
+ goto found;
+ default:
+ /* to quiet a warning from gcc*/
+ break;
+ }
+ if (js[parser->pos] < 32 || js[parser->pos] >= 127) {
+ parser->pos = start;
+ return JSMN_ERROR_INVAL;
+ }
+ }
+#ifdef JSMN_STRICT
+ /* In strict mode primitive must be followed by a comma/object/array */
+ parser->pos = start;
+ return JSMN_ERROR_PART;
+#endif
+
+found:
+ if (tokens == NULL) {
+ parser->pos--;
+ return 0;
+ }
+ token = jsmn_alloc_token(parser, tokens, num_tokens);
+ if (token == NULL) {
+ parser->pos = start;
+ return JSMN_ERROR_NOMEM;
+ }
+ jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos);
+#ifdef JSMN_PARENT_LINKS
+ token->parent = parser->toksuper;
+#endif
+ parser->pos--;
+ return 0;
+}
+
+/**
+ * Fills next token with JSON string.
+ */
+static int jsmn_parse_string(jsmn_parser *parser, const char *js,
+ const size_t len, jsmntok_t *tokens,
+ const size_t num_tokens) {
+ jsmntok_t *token;
+
+ int start = parser->pos;
+
+ /* Skip starting quote */
+ parser->pos++;
+
+ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+ char c = js[parser->pos];
+
+ /* Quote: end of string */
+ if (c == '\"') {
+ if (tokens == NULL) {
+ return 0;
+ }
+ token = jsmn_alloc_token(parser, tokens, num_tokens);
+ if (token == NULL) {
+ parser->pos = start;
+ return JSMN_ERROR_NOMEM;
+ }
+ jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos);
+#ifdef JSMN_PARENT_LINKS
+ token->parent = parser->toksuper;
+#endif
+ return 0;
+ }
+
+ /* Backslash: Quoted symbol expected */
+ if (c == '\\' && parser->pos + 1 < len) {
+ int i;
+ parser->pos++;
+ switch (js[parser->pos]) {
+ /* Allowed escaped symbols */
+ case '\"':
+ case '/':
+ case '\\':
+ case 'b':
+ case 'f':
+ case 'r':
+ case 'n':
+ case 't':
+ break;
+ /* Allows escaped symbol \uXXXX */
+ case 'u':
+ parser->pos++;
+ for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0';
+ i++) {
+ /* If it isn't a hex character we have an error */
+ if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */
+ (js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */
+ (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */
+ parser->pos = start;
+ return JSMN_ERROR_INVAL;
+ }
+ parser->pos++;
+ }
+ parser->pos--;
+ break;
+ /* Unexpected symbol */
+ default:
+ parser->pos = start;
+ return JSMN_ERROR_INVAL;
+ }
+ }
+ }
+ parser->pos = start;
+ return JSMN_ERROR_PART;
+}
+
+/**
+ * Parse JSON string and fill tokens.
+ */
+JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
+ jsmntok_t *tokens, const unsigned int num_tokens) {
+ int r;
+ int i;
+ jsmntok_t *token;
+ int count = parser->toknext;
+
+ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+ char c;
+ jsmntype_t type;
+
+ c = js[parser->pos];
+ switch (c) {
+ case '{':
+ case '[':
+ count++;
+ if (tokens == NULL) {
+ break;
+ }
+ token = jsmn_alloc_token(parser, tokens, num_tokens);
+ if (token == NULL) {
+ return JSMN_ERROR_NOMEM;
+ }
+ if (parser->toksuper != -1) {
+ jsmntok_t *t = &tokens[parser->toksuper];
+#ifdef JSMN_STRICT
+ /* In strict mode an object or array can't become a key */
+ if (t->type == JSMN_OBJECT) {
+ return JSMN_ERROR_INVAL;
+ }
+#endif
+ t->size++;
+#ifdef JSMN_PARENT_LINKS
+ token->parent = parser->toksuper;
+#endif
+ }
+ token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY);
+ token->start = parser->pos;
+ parser->toksuper = parser->toknext - 1;
+ break;
+ case '}':
+ case ']':
+ if (tokens == NULL) {
+ break;
+ }
+ type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY);
+#ifdef JSMN_PARENT_LINKS
+ if (parser->toknext < 1) {
+ return JSMN_ERROR_INVAL;
+ }
+ token = &tokens[parser->toknext - 1];
+ for (;;) {
+ if (token->start != -1 && token->end == -1) {
+ if (token->type != type) {
+ return JSMN_ERROR_INVAL;
+ }
+ token->end = parser->pos + 1;
+ parser->toksuper = token->parent;
+ break;
+ }
+ if (token->parent == -1) {
+ if (token->type != type || parser->toksuper == -1) {
+ return JSMN_ERROR_INVAL;
+ }
+ break;
+ }
+ token = &tokens[token->parent];
+ }
+#else
+ for (i = parser->toknext - 1; i >= 0; i--) {
+ token = &tokens[i];
+ if (token->start != -1 && token->end == -1) {
+ if (token->type != type) {
+ return JSMN_ERROR_INVAL;
+ }
+ parser->toksuper = -1;
+ token->end = parser->pos + 1;
+ break;
+ }
+ }
+ /* Error if unmatched closing bracket */
+ if (i == -1) {
+ return JSMN_ERROR_INVAL;
+ }
+ for (; i >= 0; i--) {
+ token = &tokens[i];
+ if (token->start != -1 && token->end == -1) {
+ parser->toksuper = i;
+ break;
+ }
+ }
+#endif
+ break;
+ case '\"':
+ r = jsmn_parse_string(parser, js, len, tokens, num_tokens);
+ if (r < 0) {
+ return r;
+ }
+ count++;
+ if (parser->toksuper != -1 && tokens != NULL) {
+ tokens[parser->toksuper].size++;
+ }
+ break;
+ case '\t':
+ case '\r':
+ case '\n':
+ case ' ':
+ break;
+ case ':':
+ parser->toksuper = parser->toknext - 1;
+ break;
+ case ',':
+ if (tokens != NULL && parser->toksuper != -1 &&
+ tokens[parser->toksuper].type != JSMN_ARRAY &&
+ tokens[parser->toksuper].type != JSMN_OBJECT) {
+#ifdef JSMN_PARENT_LINKS
+ parser->toksuper = tokens[parser->toksuper].parent;
+#else
+ for (i = parser->toknext - 1; i >= 0; i--) {
+ if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) {
+ if (tokens[i].start != -1 && tokens[i].end == -1) {
+ parser->toksuper = i;
+ break;
+ }
+ }
+ }
+#endif
+ }
+ break;
+#ifdef JSMN_STRICT
+ /* In strict mode primitives are: numbers and booleans */
+ case '-':
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ case '8':
+ case '9':
+ case 't':
+ case 'f':
+ case 'n':
+ /* And they must not be keys of the object */
+ if (tokens != NULL && parser->toksuper != -1) {
+ const jsmntok_t *t = &tokens[parser->toksuper];
+ if (t->type == JSMN_OBJECT ||
+ (t->type == JSMN_STRING && t->size != 0)) {
+ return JSMN_ERROR_INVAL;
+ }
+ }
+#else
+ /* In non-strict mode every unquoted value is a primitive */
+ default:
+#endif
+ r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens);
+ if (r < 0) {
+ return r;
+ }
+ count++;
+ if (parser->toksuper != -1 && tokens != NULL) {
+ tokens[parser->toksuper].size++;
+ }
+ break;
+
+#ifdef JSMN_STRICT
+ /* Unexpected char in strict mode */
+ default:
+ return JSMN_ERROR_INVAL;
+#endif
+ }
+ }
+
+ if (tokens != NULL) {
+ for (i = parser->toknext - 1; i >= 0; i--) {
+ /* Unmatched opened object or array */
+ if (tokens[i].start != -1 && tokens[i].end == -1) {
+ return JSMN_ERROR_PART;
+ }
+ }
+ }
+
+ return count;
+}
+
+/**
+ * Creates a new parser based over a given buffer with an array of tokens
+ * available.
+ */
+JSMN_API void jsmn_init(jsmn_parser *parser) {
+ parser->pos = 0;
+ parser->toknext = 0;
+ parser->toksuper = -1;
+}
+
+#endif /* JSMN_HEADER */
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* JSMN_H */
blob - /dev/null
blob + ae60981674ceae8be3865d47707ac23b8d49b2c2 (mode 644)
--- /dev/null
+++ src/common/json.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "log.h"
+#include "util.h"
+
+#define JSMN_STRICT
+#include "jsmn.h"
+
+#include "json.h"
+
+/* ===== Emitter ===== */
+
+void
+json_writer_init(struct json_writer *w, struct buf *b)
+{
+ w->b = b;
+ w->depth = 0;
+ w->type[0] = 't';
+ w->count[0] = 0;
+ w->after_key = 0;
+}
+
+/*
+ * Emit a separator before the next value: nothing if it directly follows a
+ * key, a comma if the current container already holds a value, and bump the
+ * element count. Aborts on a bare value inside an object (a key must precede).
+ */
+static void
+json_pre(struct json_writer *w)
+{
+ if (w->after_key) {
+ w->after_key = 0;
+ return;
+ }
+ if (w->type[w->depth] == 'o')
+ fatalx("json: value without key in object");
+ if (w->count[w->depth] > 0)
+ buf_putc(w->b, ',');
+ w->count[w->depth]++;
+}
+
+static void
+json_push(struct json_writer *w, char type, int open)
+{
+ json_pre(w);
+ buf_putc(w->b, open);
+ if (w->depth >= JSON_MAXDEPTH)
+ fatalx("json: nesting too deep");
+ w->depth++;
+ w->type[w->depth] = type;
+ w->count[w->depth] = 0;
+}
+
+static void
+json_pop(struct json_writer *w, char type, int close)
+{
+ if (w->depth <= 0 || w->type[w->depth] != type)
+ fatalx("json: mismatched container end");
+ buf_putc(w->b, close);
+ w->depth--;
+}
+
+void
+json_object_start(struct json_writer *w)
+{
+ json_push(w, 'o', '{');
+}
+
+void
+json_object_end(struct json_writer *w)
+{
+ json_pop(w, 'o', '}');
+}
+
+void
+json_array_start(struct json_writer *w)
+{
+ json_push(w, 'a', '[');
+}
+
+void
+json_array_end(struct json_writer *w)
+{
+ json_pop(w, 'a', ']');
+}
+
+static void
+json_emit_escaped(struct buf *b, const char *s, size_t len)
+{
+ size_t i;
+ unsigned char c;
+
+ buf_putc(b, '"');
+ for (i = 0; i < len; i++) {
+ c = (unsigned char)s[i];
+ switch (c) {
+ case '"':
+ buf_puts_cstr(b, "\\\"");
+ break;
+ case '\\':
+ buf_puts_cstr(b, "\\\\");
+ break;
+ case '\b':
+ buf_puts_cstr(b, "\\b");
+ break;
+ case '\f':
+ buf_puts_cstr(b, "\\f");
+ break;
+ case '\n':
+ buf_puts_cstr(b, "\\n");
+ break;
+ case '\r':
+ buf_puts_cstr(b, "\\r");
+ break;
+ case '\t':
+ buf_puts_cstr(b, "\\t");
+ break;
+ default:
+ if (c < 0x20)
+ buf_printf(b, "\\u%04x", c);
+ else
+ buf_putc(b, c); /* UTF-8 passes through */
+ }
+ }
+ buf_putc(b, '"');
+}
+
+void
+json_key(struct json_writer *w, const char *k)
+{
+ if (w->type[w->depth] != 'o')
+ fatalx("json: key outside object");
+ if (w->after_key)
+ fatalx("json: key after key");
+ if (w->count[w->depth] > 0)
+ buf_putc(w->b, ',');
+ w->count[w->depth]++;
+ json_emit_escaped(w->b, k, strlen(k));
+ buf_putc(w->b, ':');
+ w->after_key = 1;
+}
+
+void
+json_string(struct json_writer *w, const char *s)
+{
+ json_pre(w);
+ json_emit_escaped(w->b, s, strlen(s));
+}
+
+void
+json_string_n(struct json_writer *w, const char *s, size_t n)
+{
+ json_pre(w);
+ json_emit_escaped(w->b, s, n);
+}
+
+void
+json_int(struct json_writer *w, long long n)
+{
+ json_pre(w);
+ buf_printf(w->b, "%lld", n);
+}
+
+void
+json_bool(struct json_writer *w, int v)
+{
+ json_pre(w);
+ buf_puts_cstr(w->b, v ? "true" : "false");
+}
+
+void
+json_null(struct json_writer *w)
+{
+ json_pre(w);
+ buf_puts_cstr(w->b, "null");
+}
+
+void
+json_raw(struct json_writer *w, const char *r)
+{
+ json_pre(w);
+ buf_puts_cstr(w->b, r);
+}
+
+void
+json_raw_n(struct json_writer *w, const char *r, size_t n)
+{
+ json_pre(w);
+ buf_append(w->b, r, n);
+}
+
+void
+json_kv_string(struct json_writer *w, const char *k, const char *v)
+{
+ json_key(w, k);
+ json_string(w, v);
+}
+
+void
+json_kv_int(struct json_writer *w, const char *k, long long v)
+{
+ json_key(w, k);
+ json_int(w, v);
+}
+
+void
+json_kv_bool(struct json_writer *w, const char *k, int v)
+{
+ json_key(w, k);
+ json_bool(w, v);
+}
+
+/* ===== Parser ===== */
+
+/*
+ * Cap on JSON nesting depth. jsmn imposes no depth limit and json_skip() (and
+ * thus json_obj_get()) recurse once per level, so a hostile deeply-nested
+ * document could overflow the stack. Reject anything deeper than this before
+ * it reaches the recursive walkers. Generous vs any real API payload.
+ */
+#define JSON_MAXPARSE_DEPTH 256
+
+/*
+ * Bound nesting depth with an iterative pre-order walk over the flat token
+ * stream (the explicit stack is itself bounded, so this cannot recurse).
+ * Returns 1 if the document is within the depth cap, 0 if too deep.
+ */
+static int
+json_depth_ok(const jsmntok_t *tok, int ntok)
+{
+ int need[JSON_MAXPARSE_DEPTH + 1]; /* child tokens still due per level */
+ int depth = 0, i;
+
+ if (ntok <= 0)
+ return (1);
+ need[0] = ntok;
+ for (i = 0; i < ntok; i++) {
+ int children;
+
+ while (depth > 0 && need[depth] == 0)
+ depth--;
+ if (need[depth] > 0)
+ need[depth]--;
+ if (tok[i].type == JSMN_OBJECT)
+ children = tok[i].size * 2; /* key + value per member */
+ else if (tok[i].type == JSMN_ARRAY)
+ children = tok[i].size;
+ else
+ continue;
+ if (children > 0) {
+ if (depth + 1 > JSON_MAXPARSE_DEPTH)
+ return (0);
+ need[++depth] = children;
+ }
+ }
+ return (1);
+}
+
+int
+json_parse(struct json_doc *d, const char *js, size_t len)
+{
+ jsmn_parser p;
+ size_t cap = 64;
+ int r;
+
+ d->src = js;
+ d->tok = NULL;
+ d->ntok = 0;
+
+ for (;;) {
+ d->tok = xreallocarray(d->tok, cap, sizeof(jsmntok_t));
+ jsmn_init(&p);
+ r = jsmn_parse(&p, js, len, d->tok, cap);
+ if (r >= 0) {
+ if (!json_depth_ok(d->tok, r)) {
+ free(d->tok);
+ d->tok = NULL;
+ return (-1); /* nested too deep: treat as malformed */
+ }
+ d->ntok = r;
+ return (0);
+ }
+ if (r == JSMN_ERROR_NOMEM) {
+ if (cap > (size_t)INT_MAX / 2)
+ fatalx("json_parse: token count overflow");
+ cap *= 2;
+ continue;
+ }
+ /* JSMN_ERROR_INVAL or JSMN_ERROR_PART: malformed input. */
+ free(d->tok);
+ d->tok = NULL;
+ return (-1);
+ }
+}
+
+void
+json_doc_free(struct json_doc *d)
+{
+ free(d->tok);
+ d->tok = NULL;
+ d->ntok = 0;
+ d->src = NULL;
+}
+
+int
+json_tok_type(const struct json_doc *d, int i)
+{
+ return ((int)d->tok[i].type);
+}
+
+int
+json_tok_size(const struct json_doc *d, int i)
+{
+ return (d->tok[i].size);
+}
+
+int
+json_tok_streq(const struct json_doc *d, int i, const char *s)
+{
+ const jsmntok_t *t = &d->tok[i];
+ size_t n = (size_t)(t->end - t->start);
+
+ return (strlen(s) == n && memcmp(d->src + t->start, s, n) == 0);
+}
+
+int
+json_skip(const struct json_doc *d, int i)
+{
+ const jsmntok_t *t = &d->tok[i];
+ int j, k;
+
+ switch (t->type) {
+ case JSMN_OBJECT:
+ j = i + 1;
+ for (k = 0; k < t->size; k++) {
+ j = json_skip(d, j); /* key */
+ j = json_skip(d, j); /* value */
+ }
+ return (j);
+ case JSMN_ARRAY:
+ j = i + 1;
+ for (k = 0; k < t->size; k++)
+ j = json_skip(d, j);
+ return (j);
+ default:
+ return (i + 1);
+ }
+}
+
+const char *
+json_tok_raw(const struct json_doc *d, int i, size_t *len)
+{
+ const jsmntok_t *t = &d->tok[i];
+
+ *len = (size_t)(t->end - t->start);
+ return (d->src + t->start);
+}
+
+int
+json_obj_get(const struct json_doc *d, int obj, const char *key)
+{
+ int j, k, val;
+
+ if (d->tok[obj].type != JSMN_OBJECT)
+ return (-1);
+ j = obj + 1;
+ for (k = 0; k < d->tok[obj].size; k++) {
+ val = j + 1;
+ if (d->tok[j].type == JSMN_STRING && json_tok_streq(d, j, key))
+ return (val);
+ j = json_skip(d, val);
+ }
+ return (-1);
+}
+
+/*
+ * Look up a JSON_STRING field by key and decode it to a freshly allocated
+ * heap string, also reporting the true byte length through *lenp. This is
+ * the length-aware twin of obj_getstr: callers that splice the decoded
+ * bytes back into an output buffer or wire format MUST use the reported
+ * length, not strlen, because legitimate "\0" escapes decode to real NUL
+ * bytes that strlen would silently truncate. Returns NULL (with *lenp
+ * untouched-as-zero) when `obj` is negative, the key is absent, or the
+ * value is not a string; in those cases the function is a clean no-op.
+ * `lenp` may itself be NULL when only the C-string view is needed.
+ * Free the returned pointer with free(3).
+ */
+char *
+obj_getstr_n(const struct json_doc *d, int obj, const char *key, size_t *lenp)
+{
+ int t;
+
+ if (lenp != NULL)
+ *lenp = 0;
+ if (obj < 0)
+ return (NULL);
+ if ((t = json_obj_get(d, obj, key)) < 0 ||
+ json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup_n(d, t, lenp));
+}
+
+static void
+utf8_encode(struct buf *b, unsigned int cp)
+{
+ if (cp < 0x80)
+ buf_putc(b, (int)cp);
+ else if (cp < 0x800) {
+ buf_putc(b, (int)(0xc0 | (cp >> 6)));
+ buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+ } else if (cp < 0x10000) {
+ buf_putc(b, (int)(0xe0 | (cp >> 12)));
+ buf_putc(b, (int)(0x80 | ((cp >> 6) & 0x3f)));
+ buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+ } else {
+ buf_putc(b, (int)(0xf0 | (cp >> 18)));
+ buf_putc(b, (int)(0x80 | ((cp >> 12) & 0x3f)));
+ buf_putc(b, (int)(0x80 | ((cp >> 6) & 0x3f)));
+ buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+ }
+}
+
+static int
+hex4(const char *s, unsigned int *out)
+{
+ unsigned int v = 0;
+ int i, c;
+
+ for (i = 0; i < 4; i++) {
+ c = (unsigned char)s[i];
+ v <<= 4;
+ if (c >= '0' && c <= '9')
+ v |= (unsigned int)(c - '0');
+ else if (c >= 'a' && c <= 'f')
+ v |= (unsigned int)(c - 'a' + 10);
+ else if (c >= 'A' && c <= 'F')
+ v |= (unsigned int)(c - 'A' + 10);
+ else
+ return (-1);
+ }
+ *out = v;
+ return (0);
+}
+
+/*
+ * Decode token i's JSON string into a freshly allocated heap buffer. The
+ * decoded bytes are written into out and a trailing NUL is appended so callers
+ * may treat the result as a C string when they know it contains no embedded
+ * NULs. Returns out->len at the point the NUL was written so that the
+ * decoded byte count (excluding the terminator) can be reported precisely.
+ */
+static size_t
+json_tok_emit_decoded(const struct json_doc *d, int i, struct buf *out)
+{
+ const jsmntok_t *t = &d->tok[i];
+ const char *s = d->src + t->start;
+ size_t n = (size_t)(t->end - t->start);
+ size_t p = 0, decoded_len;
+ unsigned int cp, lo;
+
+ buf_init(out);
+ while (p < n) {
+ if (s[p] != '\\') {
+ buf_putc(out, s[p++]);
+ continue;
+ }
+ if (++p >= n)
+ break;
+ switch (s[p]) {
+ case '"':
+ buf_putc(out, '"');
+ p++;
+ break;
+ case '\\':
+ buf_putc(out, '\\');
+ p++;
+ break;
+ case '/':
+ buf_putc(out, '/');
+ p++;
+ break;
+ case 'b':
+ buf_putc(out, '\b');
+ p++;
+ break;
+ case 'f':
+ buf_putc(out, '\f');
+ p++;
+ break;
+ case 'n':
+ buf_putc(out, '\n');
+ p++;
+ break;
+ case 'r':
+ buf_putc(out, '\r');
+ p++;
+ break;
+ case 't':
+ buf_putc(out, '\t');
+ p++;
+ break;
+ case 'u':
+ p++;
+ if (p + 4 > n || hex4(s + p, &cp) == -1) {
+ buf_puts_cstr(out, "\\u");
+ break;
+ }
+ p += 4;
+ if (cp >= 0xd800 && cp <= 0xdbff && p + 6 <= n &&
+ s[p] == '\\' && s[p + 1] == 'u' &&
+ hex4(s + p + 2, &lo) == 0 &&
+ lo >= 0xdc00 && lo <= 0xdfff) {
+ cp = 0x10000 + ((cp - 0xd800) << 10) +
+ (lo - 0xdc00);
+ p += 6;
+ }
+ utf8_encode(out, cp);
+ break;
+ default:
+ buf_putc(out, '\\');
+ buf_putc(out, s[p]);
+ p++;
+ }
+ }
+ decoded_len = out->len;
+ buf_putc(out, '\0');
+ return (decoded_len);
+}
+
+char *
+json_tok_strdup_n(const struct json_doc *d, int i, size_t *lenp)
+{
+ struct buf out;
+ size_t decoded_len;
+
+ decoded_len = json_tok_emit_decoded(d, i, &out);
+ if (lenp != NULL)
+ *lenp = decoded_len;
+ return (out.data);
+}
+
+char *
+json_tok_strdup(const struct json_doc *d, int i)
+{
+ return (json_tok_strdup_n(d, i, NULL));
+}
blob - /dev/null
blob + a49f17cc3c78ef1854281f3b5652ed3a9aba611f (mode 644)
--- /dev/null
+++ src/common/json.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_JSON_H
+#define FUGU_JSON_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+/*
+ * JSON output (emitter) and input (a thin wrapper over the vendored jsmn
+ * tokenizer). The emitter writes into a struct buf and owns all escaping and
+ * separator placement; the parser exposes token indices into a flat array and
+ * never leaks jsmn types into the rest of the program.
+ */
+
+/* ===== Emitter ===== */
+
+#define JSON_MAXDEPTH 32
+
+struct json_writer {
+ struct buf *b;
+ int depth;
+ char type[JSON_MAXDEPTH + 1]; /* 't'op, 'o'bj, 'a'rr */
+ int count[JSON_MAXDEPTH + 1]; /* members/elements so far */
+ int after_key; /* a key awaits its value */
+};
+
+void json_writer_init(struct json_writer *, struct buf *);
+void json_object_start(struct json_writer *);
+void json_object_end(struct json_writer *);
+void json_array_start(struct json_writer *);
+void json_array_end(struct json_writer *);
+void json_key(struct json_writer *, const char *);
+void json_string(struct json_writer *, const char *);
+void json_string_n(struct json_writer *, const char *, size_t);
+void json_int(struct json_writer *, long long);
+void json_bool(struct json_writer *, int);
+void json_null(struct json_writer *);
+void json_raw(struct json_writer *, const char *); /* pre-formatted JSON */
+void json_raw_n(struct json_writer *, const char *, size_t);
+void json_kv_string(struct json_writer *, const char *, const char *);
+void json_kv_int(struct json_writer *, const char *, long long);
+void json_kv_bool(struct json_writer *, const char *, int);
+
+/* ===== Parser ===== */
+
+/* Mirrors jsmntype_t values so callers never need jsmn.h. */
+enum json_type {
+ JSON_UNDEFINED = 0,
+ JSON_OBJECT = 1 << 0,
+ JSON_ARRAY = 1 << 1,
+ JSON_STRING = 1 << 2,
+ JSON_PRIMITIVE = 1 << 3
+};
+
+struct jsmntok; /* opaque here; fully defined in json.c via jsmn.h */
+
+struct json_doc {
+ const char *src; /* borrowed; must outlive the doc */
+ struct jsmntok *tok;
+ int ntok;
+};
+
+int json_parse(struct json_doc *, const char *, size_t); /* 0 ok, -1 bad */
+void json_doc_free(struct json_doc *);
+
+int json_tok_type(const struct json_doc *, int); /* enum json_type */
+int json_tok_size(const struct json_doc *, int);
+int json_tok_streq(const struct json_doc *, int, const char *);
+/*
+ * Unescaped C string; caller free()s. STRLEN-TRUNCATING: if the decoded
+ * value carries an embedded NUL (a legitimate JSON "\0" escape), the
+ * returned pointer reads as truncated under strlen. This is fine for
+ * dispatch and equality (strcmp on small enums), but anything that flows
+ * the bytes to wire or storage MUST use json_tok_strdup_n() below.
+ */
+char *json_tok_strdup(const struct json_doc *, int);
+
+/*
+ * Like json_tok_strdup() but also reports the decoded byte length through
+ * *lenp. The returned heap string holds the JSON value's escape-decoded
+ * contents (e.g. "\0" becomes a real NUL byte) and is NUL-terminated, but
+ * since the contents may themselves contain embedded NULs, callers that need
+ * the true length MUST use *lenp instead of strlen() to avoid silent
+ * truncation. *lenp does not include the trailing NUL terminator. lenp may
+ * be NULL if the caller does not need the length. Free the returned pointer
+ * with free().
+ */
+char *json_tok_strdup_n(const struct json_doc *, int, size_t *lenp);
+
+int json_obj_get(const struct json_doc *, int, const char *);
+/*
+ * Look up a JSON_STRING field by key and decode it to a heap string.
+ * Reports the true byte length through *lenp so wire/storage writers do not
+ * have to fall back to strlen (which would truncate on legitimate embedded
+ * NULs from "\0" escapes). Returns NULL on miss / non-string / obj < 0.
+ * Free the returned pointer; lenp may be NULL.
+ */
+char *obj_getstr_n(const struct json_doc *, int obj, const char *key,
+ size_t *lenp);
+int json_skip(const struct json_doc *, int);
+
+/*
+ * Borrow a token's raw source bytes (no copy, no unescaping): for an object or
+ * array this is the whole `{...}` / `[...]` subtree, letting it be spliced into
+ * another document with json_raw_n(). The bytes live in the doc's src.
+ */
+const char *json_tok_raw(const struct json_doc *, int, size_t *len);
+
+#endif /* FUGU_JSON_H */
blob - /dev/null
blob + 76d85c723957a4d44519d9c609c19ec5f9e0474d (mode 644)
--- /dev/null
+++ src/common/log.c
+/*
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Derived from OpenBSD usr.sbin/ntpd/log.c (rev 1.19): a dest-bitmask logger
+ * that can route to stderr and/or syslog, with a multi-level verbose gate on
+ * log_debug().
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+#include <string.h>
+#include <syslog.h>
+#include <errno.h>
+#include <time.h>
+
+#include "log.h"
+
+static int dest;
+static int verbose;
+const char *log_procname;
+
+void
+log_init(int n_dest, int n_verbose, int facility)
+{
+ extern char *__progname;
+
+ dest = n_dest;
+ verbose = n_verbose;
+ log_procinit(__progname);
+
+ if (dest & LOG_TO_SYSLOG)
+ openlog(__progname, LOG_PID | LOG_NDELAY, facility);
+
+ tzset();
+}
+
+void
+log_procinit(const char *procname)
+{
+ if (procname != NULL)
+ log_procname = procname;
+}
+
+void
+log_setverbose(int v)
+{
+ verbose = v;
+}
+
+int
+log_getverbose(void)
+{
+ return (verbose);
+}
+
+void
+logit(int pri, const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vlog(pri, fmt, ap);
+ va_end(ap);
+}
+
+void
+vlog(int pri, const char *fmt, va_list ap)
+{
+ char *nfmt;
+ int saved_errno = errno;
+ va_list ap2;
+
+ va_copy(ap2, ap);
+ if (dest & LOG_TO_STDERR) {
+ /* best effort in out of mem situations */
+ if (asprintf(&nfmt, "%s\n", fmt) == -1) {
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+ } else {
+ vfprintf(stderr, nfmt, ap);
+ free(nfmt);
+ }
+ fflush(stderr);
+ }
+ if (dest & LOG_TO_SYSLOG)
+ vsyslog(pri, fmt, ap2);
+ va_end(ap2);
+
+ errno = saved_errno;
+}
+
+void
+log_warn(const char *emsg, ...)
+{
+ char *nfmt;
+ va_list ap;
+ int saved_errno = errno;
+
+ /* best effort to even work in out of memory situations */
+ if (emsg == NULL)
+ logit(LOG_ERR, "%s", strerror(saved_errno));
+ else {
+ va_start(ap, emsg);
+
+ if (asprintf(&nfmt, "%s: %s", emsg,
+ strerror(saved_errno)) == -1) {
+ /* we tried it... */
+ vlog(LOG_ERR, emsg, ap);
+ logit(LOG_ERR, "%s", strerror(saved_errno));
+ } else {
+ vlog(LOG_ERR, nfmt, ap);
+ free(nfmt);
+ }
+ va_end(ap);
+ }
+
+ errno = saved_errno;
+}
+
+void
+log_warnx(const char *emsg, ...)
+{
+ va_list ap;
+
+ va_start(ap, emsg);
+ vlog(LOG_ERR, emsg, ap);
+ va_end(ap);
+}
+
+void
+log_info(const char *emsg, ...)
+{
+ va_list ap;
+
+ va_start(ap, emsg);
+ vlog(LOG_INFO, emsg, ap);
+ va_end(ap);
+}
+
+void
+log_debug(const char *emsg, ...)
+{
+ va_list ap;
+
+ if (verbose > 1) {
+ va_start(ap, emsg);
+ vlog(LOG_DEBUG, emsg, ap);
+ va_end(ap);
+ }
+}
+
+static void vfatalc(int code, const char *emsg, va_list ap)
+ __attribute__((__format__ (printf, 2, 0)));
+
+static void
+vfatalc(int code, const char *emsg, va_list ap)
+{
+ static char s[BUFSIZ];
+ const char *sep;
+
+ if (emsg != NULL) {
+ (void)vsnprintf(s, sizeof(s), emsg, ap);
+ sep = ": ";
+ } else {
+ s[0] = '\0';
+ sep = "";
+ }
+ if (code)
+ logit(LOG_CRIT, "%s: %s%s%s",
+ log_procname, s, sep, strerror(code));
+ else
+ logit(LOG_CRIT, "%s%s%s", log_procname, sep, s);
+}
+
+void
+fatal(const char *emsg, ...)
+{
+ va_list ap;
+
+ va_start(ap, emsg);
+ vfatalc(errno, emsg, ap);
+ va_end(ap);
+ exit(1);
+}
+
+void
+fatalx(const char *emsg, ...)
+{
+ va_list ap;
+
+ va_start(ap, emsg);
+ vfatalc(0, emsg, ap);
+ va_end(ap);
+ exit(1);
+}
blob - /dev/null
blob + 2870d149cb34ef79a82ecd595f820b0b54547baf (mode 644)
--- /dev/null
+++ src/common/log.h
+/*
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_LOG_H
+#define FUGU_LOG_H
+
+#include <sys/cdefs.h>
+#include <stdarg.h>
+
+#define LOG_TO_STDERR (1<<0)
+#define LOG_TO_SYSLOG (1<<1)
+
+void log_init(int, int, int);
+void log_procinit(const char *);
+void log_setverbose(int);
+int log_getverbose(void);
+void log_warn(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+void log_warnx(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+void log_info(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+void log_debug(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+void logit(int, const char *, ...)
+ __attribute__((__format__ (printf, 2, 3)));
+void vlog(int, const char *, va_list)
+ __attribute__((__format__ (printf, 2, 0)));
+__dead void fatal(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+__dead void fatalx(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)));
+
+#endif /* FUGU_LOG_H */
blob - /dev/null
blob + 349315a4dccd169a2a47d4c9f6f6ac40e12fda4b (mode 644)
--- /dev/null
+++ src/common/mdrender.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Markdown -> styled wide text. Two passes: a line-oriented block pass
+ * (fenced code, headings, bullet lists, and dropping GFM table delimiter rows)
+ * that delegates each line's content to a recursive inline pass
+ * (bold/italic/inline-code/escapes). The structure
+ * mirrors the streaming model of the caller: it re-renders the whole buffer
+ * each frame, so an unterminated marker or open code fence simply resolves once
+ * the closing bytes arrive.
+ */
+
+#include <wchar.h>
+
+#include "mdrender.h"
+#include "util.h"
+
+#define MD_BULLET 0x2022 /* Unicode bullet (U+2022) */
+#define MD_MAXDEPTH 16 /* cap inline emphasis nesting */
+
+struct out {
+ wchar_t *wc;
+ int *st;
+ size_t n, cap;
+};
+
+static void
+emit(struct out *o, wchar_t c, int style)
+{
+ if (o->n == o->cap) {
+ o->cap = o->cap ? o->cap * 2 : 128;
+ o->wc = xreallocarray(o->wc, o->cap, sizeof(*o->wc));
+ o->st = xreallocarray(o->st, o->cap, sizeof(*o->st));
+ }
+ o->wc[o->n] = c;
+ o->st[o->n] = style;
+ o->n++;
+}
+
+/* Characters that a backslash may escape into a literal. */
+static int
+is_md_punct(wchar_t c)
+{
+ return (c == L'*' || c == L'_' || c == L'`' || c == L'#' ||
+ c == L'\\' || c == L'-' || c == L'+' || c == L'[' || c == L']' ||
+ c == L'(' || c == L')' || c == L'.' || c == L'!' || c == L'>' ||
+ c == L'|');
+}
+
+/*
+ * Inline pass over one line [w, w+n): escapes, `inline code`, and * / _ / ** /
+ * __ emphasis. Recurses (depth-capped) so emphasis can nest; an unmatched
+ * marker is emitted literally rather than swallowing the rest of the line.
+ */
+static void
+inline_md(struct out *o, const wchar_t *w, size_t n, int base, int depth)
+{
+ size_t i = 0;
+
+ while (i < n) {
+ wchar_t c = w[i];
+
+ if (c == L'\\' && i + 1 < n && is_md_punct(w[i + 1])) {
+ emit(o, w[i + 1], base); /* \* -> literal * */
+ i += 2;
+ continue;
+ }
+ if (c == L'`') { /* `inline code` */
+ size_t j = i + 1, k;
+
+ while (j < n && w[j] != L'`')
+ j++;
+ if (j < n) { /* closed on this line */
+ for (k = i + 1; k < j; k++)
+ emit(o, w[k], base | MD_CODE);
+ i = j + 1;
+ continue;
+ }
+ }
+ if ((c == L'*' || c == L'_') && depth < MD_MAXDEPTH) {
+ int dbl = (i + 1 < n && w[i + 1] == c);
+ size_t mlen = dbl ? 2 : 1;
+ size_t j = i + mlen, close = 0;
+ int found = 0;
+
+ while (j < n) { /* find a closing marker */
+ if (w[j] == c &&
+ (!dbl || (j + 1 < n && w[j + 1] == c))) {
+ close = j;
+ found = 1;
+ break;
+ }
+ j++;
+ }
+ if (found && close > i + mlen) { /* non-empty span */
+ inline_md(o, w + i + mlen, close - (i + mlen),
+ base | (dbl ? MD_BOLD : MD_ITALIC),
+ depth + 1);
+ i = close + mlen;
+ continue;
+ }
+ }
+ emit(o, c, base);
+ i++;
+ }
+}
+
+static int
+is_fence(const wchar_t *w, size_t p, size_t le)
+{
+ return (le - p >= 3 &&
+ ((w[p] == L'`' && w[p + 1] == L'`' && w[p + 2] == L'`') ||
+ (w[p] == L'~' && w[p + 1] == L'~' && w[p + 2] == L'~')));
+}
+
+/*
+ * A GFM table delimiter row -- the "|---|:-:|" line under a header. We don't
+ * lay tables out (see the renderer's block-pass notes); we just recognise this
+ * row, which is otherwise visual noise, so md_render() can drop it. It is a run
+ * of only '|', '-', ':' and blanks, with at least one of each structural mark.
+ */
+static int
+is_table_sep(const wchar_t *w, size_t p, size_t le)
+{
+ int pipe = 0, dash = 0;
+
+ for (; p < le; p++) {
+ switch (w[p]) {
+ case L'|':
+ pipe = 1;
+ break;
+ case L'-':
+ dash = 1;
+ break;
+ case L':':
+ case L' ':
+ case L'\t':
+ break;
+ default:
+ return (0); /* any other glyph: not a delimiter */
+ }
+ }
+ return (pipe && dash);
+}
+
+size_t
+md_render(const wchar_t *in, size_t n, int ascii, wchar_t **out, int **style)
+{
+ struct out o = { NULL, NULL, 0, 0 };
+ size_t i = 0;
+ int fence = 0;
+
+ while (i < n) {
+ size_t ls = i, le, p, k;
+ int had_nl, h;
+
+ while (i < n && in[i] != L'\n')
+ i++;
+ le = i; /* [ls, le) is the line body */
+ had_nl = (i < n); /* it ended with a newline */
+ if (i < n)
+ i++; /* consume the newline */
+
+ p = ls;
+ while (p < le && (in[p] == L' ' || in[p] == L'\t'))
+ p++;
+
+ /* A ``` / ~~~ fence line toggles code mode and is itself dropped. */
+ if (is_fence(in, p, le)) {
+ fence = !fence;
+ continue;
+ }
+ if (fence) { /* verbatim, dimmed, no inline parse */
+ for (k = ls; k < le; k++)
+ emit(&o, in[k], MD_CODEBLOCK);
+ if (had_nl)
+ emit(&o, L'\n', 0);
+ continue;
+ }
+
+ /* Drop a GFM table delimiter row; header/body rows render as-is. */
+ if (is_table_sep(in, p, le))
+ continue;
+
+ /* Heading: 1-6 '#' then a space; render the rest bold. */
+ h = 0;
+ p = ls;
+ while (p < le && in[p] == L'#') {
+ p++;
+ h++;
+ }
+ if (h >= 1 && h <= 6 && p < le && in[p] == L' ') {
+ while (p < le && in[p] == L' ')
+ p++;
+ inline_md(&o, in + p, le - p, MD_HEADING | MD_BOLD, 0);
+ if (had_nl)
+ emit(&o, L'\n', 0);
+ continue;
+ }
+
+ /* Bullet: indent, then - / * / + then a space -> bullet glyph. */
+ p = ls;
+ while (p < le && (in[p] == L' ' || in[p] == L'\t'))
+ p++;
+ if (p < le && (in[p] == L'-' || in[p] == L'*' || in[p] == L'+') &&
+ p + 1 < le && in[p + 1] == L' ') {
+ for (k = ls; k < p; k++) /* preserve indent */
+ emit(&o, L' ', 0);
+ emit(&o, ascii ? L'-' : MD_BULLET, 0);
+ emit(&o, L' ', 0);
+ p += 2;
+ while (p < le && in[p] == L' ')
+ p++;
+ inline_md(&o, in + p, le - p, 0, 0);
+ if (had_nl)
+ emit(&o, L'\n', 0);
+ continue;
+ }
+
+ /* Ordinary paragraph / numbered-list line. */
+ inline_md(&o, in + ls, le - ls, 0, 0);
+ if (had_nl)
+ emit(&o, L'\n', 0);
+ }
+
+ *out = o.wc;
+ *style = o.st;
+ return (o.n);
+}
blob - /dev/null
blob + 2a7f05ff94798aa4b93e9f4f56d0e1a232caff89 (mode 644)
--- /dev/null
+++ src/common/mdrender.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_MDRENDER_H
+#define FUGU_MDRENDER_H
+
+#include <stddef.h>
+#include <wchar.h>
+
+/*
+ * A small markdown-to-styled-text renderer for display. It is deliberately
+ * UI-agnostic: it emits per-character *style flags* (below), not terminal
+ * attributes, so the front end maps them to whatever it can show (curses
+ * attributes, colour, ...). Scope is the "common set" of what an assistant
+ * actually emits: headings, bold/italic/inline-code, fenced code blocks, and
+ * bullet lists. Markdown markers are removed; newlines are preserved so the
+ * caller can wrap each logical line.
+ */
+
+#define MD_BOLD 0x01 /* **bold** / __bold__ and headings */
+#define MD_ITALIC 0x02 /* *italic* / _italic_ */
+#define MD_CODE 0x04 /* `inline code` */
+#define MD_CODEBLOCK 0x08 /* inside a ``` fenced block */
+#define MD_HEADING 0x10 /* a # heading line (implies bold styling) */
+
+/*
+ * Render decoded wide text into parallel arrays of output wide characters and
+ * per-character MD_* style flags. Markdown markers are dropped and list
+ * bullets substituted (ascii != 0 selects an ASCII bullet glyph instead of a
+ * Unicode one). On return *out and *style point to malloc'd arrays the caller
+ * must free(); the return value is their length (0, with both set to NULL, for
+ * empty input). Allocation failure is fatal (xreallocarray).
+ */
+size_t md_render(const wchar_t *in, size_t n, int ascii, wchar_t **out,
+ int **style);
+
+#endif /* FUGU_MDRENDER_H */
blob - /dev/null
blob + 7445e12597b3682fe6b314ab3af7c0b62b421264 (mode 644)
--- /dev/null
+++ src/common/openai.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "json.h"
+#include "openai.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "util.h"
+
+/* Defensive cap on simultaneous tool calls reassembled from a stream. */
+#define OPENAI_MAX_TOOLCALLS 64
+
+/* ===== request builder ===== */
+
+static void emit_messages(struct json_writer *, const char *system,
+ const char *messages_json);
+static void emit_assistant(struct json_writer *, struct json_doc *,
+ int content);
+static void emit_user_blocks(struct json_writer *, struct json_doc *,
+ int content);
+static void emit_tools(struct json_writer *, const char *tools_json);
+
+static char *obj_getstr(struct json_doc *, int, const char *);
+static long long obj_getint(struct json_doc *, int, const char *, long long);
+
+void
+openai_build_request(struct buf *out, const char *model, int max_tokens,
+ const char *system, const char *messages_json, const char *tools_json)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "model", model);
+ json_kv_int(&w, "max_tokens", max_tokens);
+ json_kv_bool(&w, "stream", 1);
+ /* Ask servers that support it to report usage in the final chunk. */
+ json_key(&w, "stream_options");
+ json_object_start(&w);
+ json_kv_bool(&w, "include_usage", 1);
+ json_object_end(&w);
+
+ json_key(&w, "messages");
+ emit_messages(&w, system, messages_json);
+
+ emit_tools(&w, tools_json);
+ json_object_end(&w);
+}
+
+/* Translate the canonical Anthropic messages array into OpenAI messages. */
+static void
+emit_messages(struct json_writer *w, const char *system,
+ const char *messages_json)
+{
+ struct json_doc d;
+ int n, i, e;
+
+ json_array_start(w);
+ if (system != NULL) {
+ json_object_start(w);
+ json_kv_string(w, "role", "system");
+ json_kv_string(w, "content", system);
+ json_object_end(w);
+ }
+
+ if (json_parse(&d, messages_json, strlen(messages_json)) == -1 ||
+ d.ntok < 1 || json_tok_type(&d, 0) != JSON_ARRAY) {
+ json_doc_free(&d);
+ json_array_end(w);
+ return;
+ }
+
+ n = json_tok_size(&d, 0);
+ e = 1;
+ for (i = 0; i < n; i++) {
+ int role_t = json_obj_get(&d, e, "role");
+ int content_t = json_obj_get(&d, e, "content");
+
+ if (content_t >= 0 && json_tok_type(&d, content_t) == JSON_ARRAY) {
+ if (role_t >= 0 && json_tok_streq(&d, role_t, "assistant"))
+ emit_assistant(w, &d, content_t);
+ else
+ emit_user_blocks(w, &d, content_t);
+ } else {
+ /* String (or absent) content passes through verbatim. */
+ char *role = role_t >= 0 ?
+ json_tok_strdup(&d, role_t) : NULL;
+ char *content = content_t >= 0 ?
+ json_tok_strdup(&d, content_t) : NULL;
+
+ json_object_start(w);
+ json_kv_string(w, "role", role != NULL ? role : "user");
+ json_kv_string(w, "content",
+ content != NULL ? content : "");
+ json_object_end(w);
+ free(role);
+ free(content);
+ }
+ e = json_skip(&d, e);
+ }
+
+ json_doc_free(&d);
+ json_array_end(w);
+}
+
+/*
+ * An Anthropic assistant turn (content blocks) becomes one OpenAI assistant
+ * message: text blocks concatenate into "content", and each tool_use block
+ * becomes a tool_calls[] entry whose function.arguments is the input object
+ * serialised as a JSON string (OpenAI requires a string there).
+ */
+static void
+emit_assistant(struct json_writer *w, struct json_doc *d, int content)
+{
+ struct buf text;
+ int n, i, b, ntool = 0;
+
+ buf_init(&text);
+ n = json_tok_size(d, content);
+ b = content + 1;
+ for (i = 0; i < n; i++) {
+ int type_t = json_obj_get(d, b, "type");
+
+ if (type_t >= 0 && json_tok_streq(d, type_t, "text")) {
+ int txt = json_obj_get(d, b, "text");
+ char *s = txt >= 0 ? json_tok_strdup(d, txt) : NULL;
+
+ if (s != NULL) {
+ buf_puts_cstr(&text, s);
+ free(s);
+ }
+ } else if (type_t >= 0 && json_tok_streq(d, type_t, "tool_use"))
+ ntool++;
+ b = json_skip(d, b);
+ }
+
+ json_object_start(w);
+ json_kv_string(w, "role", "assistant");
+ json_key(w, "content");
+ json_string_n(w, text.data, text.len);
+
+ if (ntool > 0) {
+ json_key(w, "tool_calls");
+ json_array_start(w);
+ b = content + 1;
+ for (i = 0; i < n; i++) {
+ int type_t = json_obj_get(d, b, "type");
+
+ if (type_t >= 0 && json_tok_streq(d, type_t, "tool_use")) {
+ int id_t = json_obj_get(d, b, "id");
+ int name_t = json_obj_get(d, b, "name");
+ int in_t = json_obj_get(d, b, "input");
+ char *id = id_t >= 0 ?
+ json_tok_strdup(d, id_t) : NULL;
+ char *nm = name_t >= 0 ?
+ json_tok_strdup(d, name_t) : NULL;
+
+ json_object_start(w);
+ json_kv_string(w, "id", id != NULL ? id : "");
+ json_kv_string(w, "type", "function");
+ json_key(w, "function");
+ json_object_start(w);
+ json_kv_string(w, "name", nm != NULL ? nm : "");
+ json_key(w, "arguments");
+ if (in_t >= 0) {
+ size_t rlen;
+ const char *raw =
+ json_tok_raw(d, in_t, &rlen);
+
+ json_string_n(w, raw, rlen);
+ } else
+ json_string(w, "{}");
+ json_object_end(w); /* function */
+ json_object_end(w); /* tool_calls[] entry */
+ free(id);
+ free(nm);
+ }
+ b = json_skip(d, b);
+ }
+ json_array_end(w);
+ }
+
+ json_object_end(w);
+ buf_free(&text);
+}
+
+/*
+ * An Anthropic user turn carrying tool_result blocks expands to one OpenAI
+ * role:"tool" message per result; any text blocks collapse to a trailing user
+ * message (rare -- fugu's tool-result turns are pure).
+ */
+static void
+emit_user_blocks(struct json_writer *w, struct json_doc *d, int content)
+{
+ struct buf text;
+ int n, i, b, have_text = 0;
+
+ buf_init(&text);
+ n = json_tok_size(d, content);
+ b = content + 1;
+ for (i = 0; i < n; i++) {
+ int type_t = json_obj_get(d, b, "type");
+
+ if (type_t >= 0 && json_tok_streq(d, type_t, "tool_result")) {
+ int id_t = json_obj_get(d, b, "tool_use_id");
+ int c_t = json_obj_get(d, b, "content");
+ char *id = id_t >= 0 ?
+ json_tok_strdup(d, id_t) : NULL;
+ char *c = c_t >= 0 ? json_tok_strdup(d, c_t) : NULL;
+
+ json_object_start(w);
+ json_kv_string(w, "role", "tool");
+ json_kv_string(w, "tool_call_id", id != NULL ? id : "");
+ json_kv_string(w, "content", c != NULL ? c : "");
+ json_object_end(w);
+ free(id);
+ free(c);
+ } else if (type_t >= 0 && json_tok_streq(d, type_t, "text")) {
+ int txt = json_obj_get(d, b, "text");
+ char *s = txt >= 0 ? json_tok_strdup(d, txt) : NULL;
+
+ if (s != NULL) {
+ buf_puts_cstr(&text, s);
+ have_text = 1;
+ free(s);
+ }
+ }
+ b = json_skip(d, b);
+ }
+
+ if (have_text) {
+ json_object_start(w);
+ json_kv_string(w, "role", "user");
+ json_key(w, "content");
+ json_string_n(w, text.data, text.len);
+ json_object_end(w);
+ }
+ buf_free(&text);
+}
+
+/* Rewrap the Anthropic tool list as OpenAI functions; omit if empty. */
+static void
+emit_tools(struct json_writer *w, const char *tools_json)
+{
+ struct json_doc d;
+ int n, i, e;
+
+ if (tools_json == NULL)
+ return;
+ if (json_parse(&d, tools_json, strlen(tools_json)) == -1 ||
+ d.ntok < 1 || json_tok_type(&d, 0) != JSON_ARRAY) {
+ json_doc_free(&d);
+ return;
+ }
+ n = json_tok_size(&d, 0);
+ if (n == 0) {
+ json_doc_free(&d);
+ return;
+ }
+
+ json_key(w, "tools");
+ json_array_start(w);
+ e = 1;
+ for (i = 0; i < n; i++) {
+ char *name = obj_getstr(&d, e, "name");
+ char *desc = obj_getstr(&d, e, "description");
+ int schema_t = json_obj_get(&d, e, "input_schema");
+
+ json_object_start(w);
+ json_kv_string(w, "type", "function");
+ json_key(w, "function");
+ json_object_start(w);
+ json_kv_string(w, "name", name != NULL ? name : "");
+ if (desc != NULL)
+ json_kv_string(w, "description", desc);
+ json_key(w, "parameters");
+ if (schema_t >= 0) {
+ size_t rlen;
+ const char *raw = json_tok_raw(&d, schema_t, &rlen);
+
+ json_raw_n(w, raw, rlen);
+ } else
+ json_raw(w, "{\"type\":\"object\"}");
+ json_object_end(w); /* function */
+ json_object_end(w); /* tools[] entry */
+ free(name);
+ free(desc);
+ e = json_skip(&d, e);
+ }
+ json_array_end(w);
+ json_doc_free(&d);
+}
+
+/* ===== streaming response decoder ===== */
+
+static void accumulate_toolcalls(struct openai_stream *, struct json_doc *,
+ int arr);
+static void flush_toolcalls(struct openai_stream *);
+static struct openai_toolcall *toolslot(struct openai_stream *, int);
+static const char *map_stop(const char *);
+
+void
+openai_stream_init(struct openai_stream *s, const struct llm_cbs *cbs, void *arg)
+{
+ memset(s, 0, sizeof(*s));
+ s->cbs = *cbs;
+ s->arg = arg;
+}
+
+void
+openai_stream_clear(struct openai_stream *s)
+{
+ int i;
+
+ for (i = 0; i < s->ntools; i++) {
+ buf_free(&s->tools[i].id);
+ buf_free(&s->tools[i].name);
+ buf_free(&s->tools[i].args);
+ }
+ free(s->tools);
+ memset(s, 0, sizeof(*s));
+}
+
+void
+openai_stream_event(struct openai_stream *s, const char *event,
+ const char *data, size_t datalen)
+{
+ struct json_doc d;
+ int err, usage, choices, c0, delta, t;
+
+ (void)event; /* OpenAI sends bare data lines (event defaults to "message") */
+ if (s->error)
+ return;
+
+ /* The stream terminates with a literal "[DONE]" sentinel, not JSON. */
+ if (datalen == 6 && memcmp(data, "[DONE]", 6) == 0) {
+ flush_toolcalls(s); /* in case a server omits the finish_reason chunk */
+ if (s->cbs.on_done != NULL)
+ s->cbs.on_done(s->arg);
+ return;
+ }
+
+ if (json_parse(&d, data, datalen) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ s->error = 1;
+ if (s->cbs.on_error != NULL)
+ s->cbs.on_error("parse_error", "invalid event JSON",
+ s->arg);
+ return;
+ }
+
+ /* Mid-stream error envelope: {"error":{"message":..,"type":..}}. */
+ if ((err = json_obj_get(&d, 0, "error")) >= 0 &&
+ json_tok_type(&d, err) == JSON_OBJECT) {
+ char *type = obj_getstr(&d, err, "type");
+ char *msg = obj_getstr(&d, err, "message");
+
+ s->error = 1;
+ if (s->cbs.on_error != NULL)
+ s->cbs.on_error(type != NULL ? type : "error",
+ msg != NULL ? msg : "", s->arg);
+ free(type);
+ free(msg);
+ json_doc_free(&d);
+ return;
+ }
+
+ /* Usage block: present in the final chunk when include_usage is honored. */
+ if ((usage = json_obj_get(&d, 0, "usage")) >= 0 &&
+ json_tok_type(&d, usage) == JSON_OBJECT) {
+ s->input_tokens = obj_getint(&d, usage, "prompt_tokens",
+ s->input_tokens);
+ s->output_tokens = obj_getint(&d, usage, "completion_tokens",
+ s->output_tokens);
+ if (s->cbs.on_usage != NULL)
+ s->cbs.on_usage(s->input_tokens, s->output_tokens,
+ s->arg);
+ }
+
+ choices = json_obj_get(&d, 0, "choices");
+ if (choices < 0 || json_tok_type(&d, choices) != JSON_ARRAY ||
+ json_tok_size(&d, choices) < 1) {
+ json_doc_free(&d);
+ return;
+ }
+ c0 = choices + 1; /* first (and only) choice */
+
+ if ((delta = json_obj_get(&d, c0, "delta")) >= 0 &&
+ json_tok_type(&d, delta) == JSON_OBJECT) {
+ if ((t = json_obj_get(&d, delta, "content")) >= 0 &&
+ json_tok_type(&d, t) == JSON_STRING) {
+ size_t fraglen;
+ char *frag = json_tok_strdup_n(&d, t, &fraglen);
+
+ if (frag != NULL) {
+ if (s->cbs.on_text != NULL)
+ s->cbs.on_text(frag, fraglen, s->arg);
+ free(frag);
+ }
+ }
+ if ((t = json_obj_get(&d, delta, "tool_calls")) >= 0 &&
+ json_tok_type(&d, t) == JSON_ARRAY)
+ accumulate_toolcalls(s, &d, t);
+ }
+
+ /* finish_reason closes the assistant message: flush tools, then stop. */
+ if ((t = json_obj_get(&d, c0, "finish_reason")) >= 0 &&
+ json_tok_type(&d, t) == JSON_STRING) {
+ char *fr = json_tok_strdup(&d, t);
+
+ flush_toolcalls(s);
+ if (s->cbs.on_stop != NULL)
+ s->cbs.on_stop(map_stop(fr), s->arg);
+ free(fr);
+ }
+
+ json_doc_free(&d);
+}
+
+/* Merge one streamed tool_calls array into the per-index reassembly slots. */
+static void
+accumulate_toolcalls(struct openai_stream *s, struct json_doc *d, int arr)
+{
+ int n = json_tok_size(d, arr);
+ int e = arr + 1, i;
+
+ for (i = 0; i < n; i++) {
+ struct openai_toolcall *tc;
+ const char *errstr;
+ int idx_t = json_obj_get(d, e, "index");
+ int id_t = json_obj_get(d, e, "id");
+ int fn_t = json_obj_get(d, e, "function");
+ int idx = 0;
+
+ if (idx_t >= 0) {
+ char *v = json_tok_strdup(d, idx_t);
+
+ if (v != NULL) {
+ idx = (int)strtonum(v, 0,
+ OPENAI_MAX_TOOLCALLS - 1, &errstr);
+ if (errstr != NULL)
+ idx = -1;
+ free(v);
+ }
+ }
+ if ((tc = toolslot(s, idx)) == NULL) {
+ e = json_skip(d, e);
+ continue;
+ }
+ tc->seen = 1;
+ /*
+ * id/name/arguments are only consumed when the delta actually
+ * carries a JSON string: continuation deltas commonly omit them,
+ * and an emulating server may send an explicit null -- which
+ * json_tok_strdup would otherwise copy verbatim as "null",
+ * clobbering the real value captured by an earlier delta.
+ */
+ if (id_t >= 0 && json_tok_type(d, id_t) == JSON_STRING) {
+ size_t vlen;
+ char *v = json_tok_strdup_n(d, id_t, &vlen);
+
+ if (v != NULL && vlen > 0) {
+ buf_reset(&tc->id);
+ buf_append(&tc->id, v, vlen);
+ }
+ free(v);
+ }
+ if (fn_t >= 0 && json_tok_type(d, fn_t) == JSON_OBJECT) {
+ int name_t = json_obj_get(d, fn_t, "name");
+ int args_t = json_obj_get(d, fn_t, "arguments");
+
+ if (name_t >= 0 && json_tok_type(d, name_t) == JSON_STRING) {
+ size_t vlen;
+ char *v = json_tok_strdup_n(d, name_t, &vlen);
+
+ if (v != NULL && vlen > 0) {
+ buf_reset(&tc->name);
+ buf_append(&tc->name, v, vlen);
+ }
+ free(v);
+ }
+ if (args_t >= 0 &&
+ json_tok_type(d, args_t) == JSON_STRING) {
+ size_t vlen;
+ char *v = json_tok_strdup_n(d, args_t, &vlen);
+
+ if (v != NULL) {
+ buf_append(&tc->args, v, vlen);
+ free(v);
+ }
+ }
+ }
+ e = json_skip(d, e);
+ }
+}
+
+/* Deliver every reassembled tool call exactly once, in index order. */
+static void
+flush_toolcalls(struct openai_stream *s)
+{
+ int i;
+
+ if (s->flushed)
+ return;
+ s->flushed = 1;
+ for (i = 0; i < s->ntools; i++) {
+ struct openai_toolcall *tc = &s->tools[i];
+ const char *args;
+ size_t alen;
+
+ if (!tc->seen)
+ continue;
+ buf_terminate(&tc->id); /* NUL-terminate, uncounted */
+ buf_terminate(&tc->name);
+ if (tc->args.len > 0) {
+ buf_terminate(&tc->args);
+ args = tc->args.data;
+ alen = tc->args.len;
+ } else {
+ args = "{}";
+ alen = 2;
+ }
+ if (s->cbs.on_tool_call != NULL)
+ s->cbs.on_tool_call(tc->id.data, tc->name.data, args,
+ alen, s->arg);
+ }
+}
+
+/* Find (growing as needed) the reassembly slot for a tool-call index. */
+static struct openai_toolcall *
+toolslot(struct openai_stream *s, int idx)
+{
+ int old, i;
+
+ if (idx < 0 || idx >= OPENAI_MAX_TOOLCALLS)
+ return (NULL);
+ if (idx >= s->ntools) {
+ old = s->ntools;
+ s->tools = xreallocarray(s->tools, (size_t)idx + 1,
+ sizeof(*s->tools));
+ for (i = old; i <= idx; i++) {
+ buf_init(&s->tools[i].id);
+ buf_init(&s->tools[i].name);
+ buf_init(&s->tools[i].args);
+ s->tools[i].seen = 0;
+ }
+ s->ntools = idx + 1;
+ }
+ return (&s->tools[idx]);
+}
+
+/* Map an OpenAI finish_reason onto fugu's Anthropic-flavored stop_reason. */
+static const char *
+map_stop(const char *fr)
+{
+ if (fr == NULL)
+ return ("end_turn");
+ if (strcmp(fr, "tool_calls") == 0)
+ return ("tool_use");
+ if (strcmp(fr, "length") == 0)
+ return ("max_tokens");
+ if (strcmp(fr, "stop") == 0)
+ return ("end_turn");
+ return (fr); /* content_filter and friends pass through */
+}
+
+/* Duplicate an object member as a C string (string or primitive); NULL if absent. */
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
+
+static long long
+obj_getint(struct json_doc *d, int obj, const char *key, long long dflt)
+{
+ const char *errstr;
+ char *s;
+ long long v;
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0 || (s = json_tok_strdup(d, t)) == NULL)
+ return (dflt);
+ v = strtonum(s, LLONG_MIN, LLONG_MAX, &errstr);
+ free(s);
+ return (errstr != NULL ? dflt : v);
+}
+
+/* ===== vtable adapters ===== */
+
+/*
+ * Default request path for the chat/completions endpoint. net_run's models-
+ * endpoint derivation swaps "/chat/completions" out of this when synthesising
+ * the models list URL.
+ */
+#define OPENAI_PATH "/v1/chat/completions"
+
+/*
+ * Build a streaming chat/completions request: body via openai_build_request,
+ * plus the OpenAI-style header set (Authorization: Bearer, content-type,
+ * accept). authval holds the bearer string so the caller can buf_freezero it
+ * after http_build_request has captured it; the authorization header points
+ * into authval->data.
+ */
+static void
+openai_prep_request(struct buf *body, struct http_header *h, int hmax,
+ int *nh, struct buf *authval, const char **path, const char *key,
+ const char *model, int max_tokens, const char *system, const char *conv,
+ const char *tools)
+{
+ if (hmax < 3)
+ return; /* caller bug; net worker sizes h[4] */
+ openai_build_request(body, model, max_tokens, system, conv, tools);
+ buf_printf(authval, "Bearer %s", key);
+ buf_terminate(authval); /* NUL-terminate, uncounted */
+ h[0].name = "authorization"; h[0].value = authval->data;
+ h[1].name = "content-type"; h[1].value = "application/json";
+ h[2].name = "accept"; h[2].value = "text/event-stream";
+ *nh = 3;
+ *path = OPENAI_PATH;
+}
+
+static void
+openai_stream_init_op(union llm_stream_storage *s,
+ const struct llm_cbs *cbs, void *arg)
+{
+ openai_stream_init(&s->o, cbs, arg);
+}
+
+static void
+openai_stream_event_op(union llm_stream_storage *s, const char *event,
+ const char *data, size_t len)
+{
+ openai_stream_event(&s->o, event, data, len);
+}
+
+static void
+openai_stream_clear_op(union llm_stream_storage *s)
+{
+ openai_stream_clear(&s->o);
+}
+
+static const char *
+openai_models_path_op(void)
+{
+ return (OPENAI_PATH);
+}
+
+const struct llm_provider_ops openai_ops = {
+ .prep_request = openai_prep_request,
+ .stream_init = openai_stream_init_op,
+ .stream_event = openai_stream_event_op,
+ .stream_clear = openai_stream_clear_op,
+ .models_path = openai_models_path_op,
+};
blob - /dev/null
blob + cfccc69630162f34bb4ecc4bb02b0c08cf84d412 (mode 644)
--- /dev/null
+++ src/common/openai.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * openai: the provider layer for the OpenAI chat/completions API (and the many
+ * vendors and local servers that emulate it). Like anthropic.c it is two pure,
+ * socket-free halves -- openai_build_request() serialises a streaming request
+ * body, and struct openai_stream interprets the streamed response.
+ *
+ * fugu keeps its conversation in the canonical Anthropic shape, so the builder
+ * TRANSLATES on the way out: the Anthropic messages array (string or content-
+ * block form) collapses to OpenAI roles (assistant tool_use -> tool_calls, a
+ * user turn of tool_result blocks -> role:"tool" messages), the system prompt
+ * folds into a leading system message, and the tool list is rewrapped as
+ * {"type":"function","function":{...}}. The decoder fires the same llm_cbs as
+ * anthropic, so the net worker is identical for either provider.
+ */
+
+#ifndef FUGU_OPENAI_H
+#define FUGU_OPENAI_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "provider.h"
+
+/*
+ * Serialise a streaming chat/completions request body into out. system is
+ * emitted as a leading {"role":"system"} message when non-NULL. messages_json
+ * and tools_json are the canonical Anthropic-shaped arrays; tools_json may be
+ * NULL or "[]" to send no tools.
+ */
+void openai_build_request(struct buf *out, const char *model,
+ int max_tokens, const char *system, const char *messages_json,
+ const char *tools_json);
+
+/* One in-flight tool call, reassembled from streamed deltas by index. */
+struct openai_toolcall {
+ struct buf id;
+ struct buf name;
+ struct buf args; /* accumulated arguments JSON text */
+ int seen;
+};
+
+struct openai_stream {
+ struct llm_cbs cbs;
+ void *arg;
+ struct openai_toolcall *tools; /* indexed by choices[].delta index */
+ int ntools;
+ long long input_tokens;
+ long long output_tokens;
+ int flushed; /* tool calls already delivered */
+ int error; /* a fatal stream error was seen */
+};
+
+void openai_stream_init(struct openai_stream *, const struct llm_cbs *,
+ void *);
+void openai_stream_clear(struct openai_stream *);
+
+/*
+ * Feed one SSE event (the event type is ignored; OpenAI sends bare data lines
+ * and a final "[DONE]" sentinel). Shaped to be called from an sse_event_cb
+ * trampoline.
+ */
+void openai_stream_event(struct openai_stream *, const char *event,
+ const char *data, size_t datalen);
+
+/*
+ * Vtable instance -- see provider.h. Defined in openai.c; resolved onto a
+ * struct net_provider at config-apply time so the net worker can dispatch
+ * prep_request / stream_init / stream_event / stream_clear / models_path
+ * without branching on enum llm_provider. Forward-declared here because
+ * provider.h pulls this header in before completing struct llm_provider_ops.
+ */
+struct llm_provider_ops;
+extern const struct llm_provider_ops openai_ops;
+
+#endif /* FUGU_OPENAI_H */
blob - /dev/null
blob + 037d5ea15dbab6cbaeee723951ad8ff8f1e3c64b (mode 644)
--- /dev/null
+++ src/common/parse.y
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+%{
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <ctype.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+static struct file {
+ struct file *prev;
+ FILE *stream;
+ char *name;
+ size_t ungetpos;
+ size_t ungetsize;
+ u_char *ungetbuf;
+ int eof_reached;
+ int secret; /* StrictModes-check this file */
+ int lineno;
+ int errors;
+} *file, *topfile;
+struct file *pushfile(const char *, int);
+int popfile(void);
+int yyparse(void);
+int yylex(void);
+int yyerror(const char *, ...)
+ __attribute__((__format__ (printf, 1, 2)))
+ __attribute__((__nonnull__ (1)));
+int kw_cmp(const void *, const void *);
+int lookup(char *);
+int igetc(void);
+int lgetc(int);
+void lungetc(int);
+int findeol(void);
+
+TAILQ_HEAD(symhead, sym) symhead = TAILQ_HEAD_INITIALIZER(symhead);
+struct sym {
+ TAILQ_ENTRY(sym) entry;
+ int persist;
+ char *nam;
+ char *val;
+};
+int symset(const char *, const char *, int);
+char *symget(const char *);
+
+static struct conf *conf;
+static struct conf_settings *cur; /* settings being filled (global or block) */
+
+typedef struct {
+ union {
+ long long number;
+ char *string;
+ } v;
+ int lineno;
+} YYSTYPE;
+
+%}
+
+%token MODEL SYSTEM MAX_TOKENS CONTEXT_LIMIT ALLOW_SUBPROCESS_NET ALLOW_WRITE
+%token WEB_SEARCH SEARCH_PROVIDER ANTHROPIC_KEY KAGI_TOKEN ASCII
+%token ANTHROPIC_KEY_FILE KAGI_TOKEN_FILE
+%token PROVIDER TYPE API_KEY API_KEY_FILE API_HOST API_PORT API_PATH
+%token HTTP_ALLOW HTTP_BLOCK
+%token MATCH USER GROUP INCLUDE YES NO
+%token ERROR
+%token <v.string> STRING
+%token <v.number> NUMBER
+%type <v.number> yesno matchtype
+
+%%
+
+grammar : /* empty */
+ | grammar '\n'
+ | grammar include '\n'
+ | grammar varset '\n'
+ | grammar setting '\n'
+ | grammar match '\n'
+ | grammar providerblock '\n'
+ | grammar error '\n' { file->errors++; }
+ ;
+
+include : INCLUDE STRING {
+ struct file *nfile;
+
+ if ((nfile = pushfile($2, file->secret)) == NULL) {
+ yyerror("failed to include file %s", $2);
+ free($2);
+ YYERROR;
+ }
+ free($2);
+ file = nfile;
+ lungetc('\n');
+ }
+ ;
+
+varset : STRING '=' STRING {
+ char *s;
+
+ for (s = $1; *s != '\0'; s++) {
+ if (isspace((unsigned char)*s)) {
+ yyerror("macro name may not contain "
+ "whitespace");
+ free($1);
+ free($3);
+ YYERROR;
+ }
+ }
+ symset($1, $3, 0);
+ free($1);
+ free($3);
+ }
+ ;
+
+setting : MODEL STRING {
+ conf_set_string(cur, CS_MODEL, &cur->model, $2);
+ free($2);
+ }
+ | SYSTEM STRING {
+ conf_set_string(cur, CS_SYSTEM, &cur->system, $2);
+ free($2);
+ }
+ | MAX_TOKENS NUMBER {
+ cur->max_tokens = $2;
+ conf_set_flag(cur, CS_MAX_TOKENS);
+ }
+ | CONTEXT_LIMIT NUMBER {
+ cur->context_limit = $2;
+ conf_set_flag(cur, CS_CONTEXT_LIMIT);
+ }
+ | ALLOW_SUBPROCESS_NET yesno {
+ cur->allow_subprocess_net = $2;
+ conf_set_flag(cur, CS_ALLOW_SUBPROC_NET);
+ }
+ | ALLOW_WRITE yesno {
+ cur->allow_write = $2;
+ conf_set_flag(cur, CS_ALLOW_WRITE);
+ }
+ | WEB_SEARCH yesno {
+ cur->web_search = $2;
+ conf_set_flag(cur, CS_WEB_SEARCH);
+ }
+ | ASCII yesno {
+ cur->ascii = $2;
+ conf_set_flag(cur, CS_ASCII);
+ }
+ | SEARCH_PROVIDER STRING {
+ conf_set_string(cur, CS_SEARCH_PROVIDER,
+ &cur->search_provider, $2);
+ free($2);
+ }
+ | ANTHROPIC_KEY STRING {
+ conf_set_string(cur, CS_ANTHROPIC_KEY,
+ &cur->anthropic_key, $2);
+ free($2);
+ }
+ | KAGI_TOKEN STRING {
+ conf_set_string(cur, CS_KAGI_TOKEN,
+ &cur->kagi_token, $2);
+ free($2);
+ }
+ | ANTHROPIC_KEY_FILE STRING {
+ conf_set_string(cur, CS_ANTHROPIC_KEY_FILE,
+ &cur->anthropic_key_file, $2);
+ free($2);
+ }
+ | KAGI_TOKEN_FILE STRING {
+ conf_set_string(cur, CS_KAGI_TOKEN_FILE,
+ &cur->kagi_token_file, $2);
+ free($2);
+ }
+ | PROVIDER STRING {
+ conf_set_string(cur, CS_PROVIDER, &cur->provider, $2);
+ free($2);
+ }
+ | TYPE STRING {
+ conf_set_string(cur, CS_PROVIDER, &cur->provider, $2);
+ free($2);
+ }
+ | API_KEY STRING {
+ conf_set_string(cur, CS_API_KEY, &cur->api_key, $2);
+ free($2);
+ }
+ | API_KEY_FILE STRING {
+ conf_set_string(cur, CS_API_KEY_FILE,
+ &cur->api_key_file, $2);
+ free($2);
+ }
+ | API_HOST STRING {
+ conf_set_string(cur, CS_API_HOST, &cur->api_host, $2);
+ free($2);
+ }
+ | API_PORT STRING {
+ conf_set_string(cur, CS_API_PORT, &cur->api_port, $2);
+ free($2);
+ }
+ | API_PATH STRING {
+ conf_set_string(cur, CS_API_PATH, &cur->api_path, $2);
+ free($2);
+ }
+ | HTTP_ALLOW STRING {
+ conf_set_string(cur, CS_HTTP_ALLOW, &cur->http_allow, $2);
+ free($2);
+ }
+ | HTTP_BLOCK STRING {
+ conf_set_string(cur, CS_HTTP_BLOCK, &cur->http_block, $2);
+ free($2);
+ }
+ ;
+
+yesno : YES { $$ = 1; }
+ | NO { $$ = 0; }
+ ;
+
+matchtype : USER { $$ = MATCH_USER; }
+ | GROUP { $$ = MATCH_GROUP; }
+ ;
+
+match : MATCH matchtype STRING '{' {
+ cur = &conf_match_new(conf,
+ (enum match_kind)$2, $3)->settings;
+ free($3);
+ } optnl matchopts_l '}' {
+ cur = &conf->global;
+ }
+ ;
+
+matchopts_l : /* empty */
+ | matchopts_l setting optnl
+ ;
+
+/*
+ * A named provider block: `provider "name" { type ...; api_key ...; ... }`.
+ * Distinct from the flat `provider "type"` setting above -- the trailing '{'
+ * is what tells them apart (LALR resolves on that one-token lookahead).
+ */
+providerblock : PROVIDER STRING '{' {
+ cur = &conf_provider_new(conf, $2)->settings;
+ free($2);
+ } optnl matchopts_l '}' {
+ cur = &conf->global;
+ }
+ ;
+
+optnl : /* empty */
+ | optnl '\n'
+ ;
+
+%%
+
+int
+yyerror(const char *fmt, ...)
+{
+ va_list ap;
+ char *msg;
+
+ file->errors++;
+ va_start(ap, fmt);
+ if (vasprintf(&msg, fmt, ap) == -1)
+ fatalx("yyerror vasprintf");
+ va_end(ap);
+ log_warnx("%s:%d: %s", file->name, yylval.lineno, msg);
+ free(msg);
+ return (0);
+}
+
+struct keywords {
+ const char *k_name;
+ int k_val;
+};
+
+int
+kw_cmp(const void *k, const void *e)
+{
+ return (strcmp(k, ((const struct keywords *)e)->k_name));
+}
+
+int
+lookup(char *s)
+{
+ /* this has to be sorted always */
+ static const struct keywords keywords[] = {
+ { "allow_subprocess_net", ALLOW_SUBPROCESS_NET },
+ { "allow_write", ALLOW_WRITE },
+ { "anthropic_key", ANTHROPIC_KEY },
+ { "anthropic_key_file", ANTHROPIC_KEY_FILE },
+ { "api_host", API_HOST },
+ { "api_key", API_KEY },
+ { "api_key_file", API_KEY_FILE },
+ { "api_path", API_PATH },
+ { "api_port", API_PORT },
+ { "ascii", ASCII },
+ { "context_limit", CONTEXT_LIMIT },
+ { "group", GROUP },
+ { "http_allow", HTTP_ALLOW },
+ { "http_block", HTTP_BLOCK },
+ { "include", INCLUDE },
+ { "kagi_token", KAGI_TOKEN },
+ { "kagi_token_file", KAGI_TOKEN_FILE },
+ { "match", MATCH },
+ { "max_tokens", MAX_TOKENS },
+ { "model", MODEL },
+ { "no", NO },
+ { "provider", PROVIDER },
+ { "search_provider", SEARCH_PROVIDER },
+ { "system", SYSTEM },
+ { "type", TYPE },
+ { "user", USER },
+ { "web_search", WEB_SEARCH },
+ { "yes", YES }
+ };
+ const struct keywords *p;
+
+ p = bsearch(s, keywords, sizeof(keywords) / sizeof(keywords[0]),
+ sizeof(keywords[0]), kw_cmp);
+
+ if (p)
+ return (p->k_val);
+ else
+ return (STRING);
+}
+
+/*
+ * Macro expansion is done by pushing the macro's value back onto the input,
+ * bracketed by START_EXPAND/DONE_EXPAND sentinels that igetc() turns into the
+ * `expanding` state. This mirrors the current base lexer template
+ * (usr.sbin/httpd/parse.y) and supports nested macros via a growable per-file
+ * unget buffer, replacing the old fixed 128-byte pushback array.
+ */
+#define START_EXPAND 1
+#define DONE_EXPAND 2
+
+static int expanding;
+
+int
+igetc(void)
+{
+ int c;
+
+ while (1) {
+ if (file->ungetpos > 0)
+ c = file->ungetbuf[--file->ungetpos];
+ else
+ c = getc(file->stream);
+
+ if (c == START_EXPAND)
+ expanding = 1;
+ else if (c == DONE_EXPAND)
+ expanding = 0;
+ else
+ break;
+ }
+ return (c);
+}
+
+int
+lgetc(int quotec)
+{
+ int c, next;
+
+ if (quotec) {
+ if ((c = igetc()) == EOF) {
+ yyerror("reached end of file while parsing "
+ "quoted string");
+ if (file == topfile || popfile() == EOF)
+ return (EOF);
+ return (quotec);
+ }
+ return (c);
+ }
+
+ while ((c = igetc()) == '\\') {
+ next = igetc();
+ if (next != '\n') {
+ c = next;
+ break;
+ }
+ yylval.lineno = file->lineno;
+ file->lineno++;
+ }
+
+ if (c == EOF) {
+ /*
+ * Fake EOL when hitting EOF for the first time, so the line
+ * count is right if the last line lacks a trailing newline.
+ */
+ if (file->eof_reached == 0) {
+ file->eof_reached = 1;
+ return ('\n');
+ }
+ while (c == EOF) {
+ if (file == topfile || popfile() == EOF)
+ return (EOF);
+ c = igetc();
+ }
+ }
+ return (c);
+}
+
+void
+lungetc(int c)
+{
+ if (c == EOF)
+ return;
+
+ if (file->ungetpos >= file->ungetsize) {
+ file->ungetbuf = xreallocarray(file->ungetbuf,
+ file->ungetsize, 2);
+ file->ungetsize *= 2;
+ }
+ file->ungetbuf[file->ungetpos++] = c;
+}
+
+int
+findeol(void)
+{
+ int c;
+
+ /* skip to either EOF or the first real EOL */
+ while (1) {
+ c = lgetc(0);
+ if (c == '\n') {
+ file->lineno++;
+ break;
+ }
+ if (c == EOF)
+ break;
+ }
+ return (ERROR);
+}
+
+int
+yylex(void)
+{
+ char buf[8096];
+ char *p, *val;
+ int quotec, next, c;
+ int token;
+
+top:
+ p = buf;
+ while ((c = lgetc(0)) == ' ' || c == '\t')
+ ; /* nothing */
+
+ yylval.lineno = file->lineno;
+ if (c == '#')
+ while ((c = lgetc(0)) != '\n' && c != EOF)
+ ; /* nothing */
+ if (c == '$' && !expanding) {
+ while (1) {
+ if ((c = lgetc(0)) == EOF)
+ return (0);
+ if (p + 1 >= buf + sizeof(buf) - 1) {
+ yyerror("string too long");
+ return (findeol());
+ }
+ if (isalnum(c) || c == '_') {
+ *p++ = c;
+ continue;
+ }
+ *p = '\0';
+ lungetc(c);
+ break;
+ }
+ val = symget(buf);
+ if (val == NULL) {
+ yyerror("macro '%s' not defined", buf);
+ return (findeol());
+ }
+ lungetc(DONE_EXPAND);
+ for (size_t i = strlen(val); i > 0; i--)
+ lungetc((unsigned char)val[i - 1]);
+ lungetc(START_EXPAND);
+ goto top;
+ }
+
+ switch (c) {
+ case '\'':
+ case '"':
+ quotec = c;
+ while (1) {
+ if ((c = lgetc(quotec)) == EOF)
+ return (0);
+ if (c == '\n') {
+ file->lineno++;
+ continue;
+ } else if (c == '\\') {
+ if ((next = lgetc(quotec)) == EOF)
+ return (0);
+ if (next == quotec || next == ' ' ||
+ next == '\t')
+ c = next;
+ else if (next == '\n') {
+ file->lineno++;
+ continue;
+ } else
+ lungetc(next);
+ } else if (c == quotec) {
+ *p = '\0';
+ break;
+ } else if (c == '\0') {
+ yyerror("syntax error");
+ return (findeol());
+ }
+ if (p + 1 >= buf + sizeof(buf) - 1) {
+ yyerror("string too long");
+ return (findeol());
+ }
+ *p++ = c;
+ }
+ yylval.v.string = strdup(buf);
+ if (yylval.v.string == NULL)
+ fatal("yylex: strdup");
+ return (STRING);
+ }
+
+#define allowed_to_end_number(x) \
+ (isspace(x) || x == ')' || x ==',' || x == '/' || x == '}' || x == '=')
+
+ if (c == '-' || isdigit(c)) {
+ do {
+ *p++ = c;
+ if ((size_t)(p-buf) >= sizeof(buf)) {
+ yyerror("string too long");
+ return (findeol());
+ }
+ } while ((c = lgetc(0)) != EOF && isdigit(c));
+ lungetc(c);
+ if (p == buf + 1 && buf[0] == '-')
+ goto nodigits;
+ if (c == EOF || allowed_to_end_number(c)) {
+ const char *errstr = NULL;
+
+ *p = '\0';
+ yylval.v.number = strtonum(buf, LLONG_MIN,
+ LLONG_MAX, &errstr);
+ if (errstr) {
+ yyerror("\"%s\" invalid number: %s",
+ buf, errstr);
+ return (findeol());
+ }
+ return (NUMBER);
+ } else {
+nodigits:
+ while (p > buf + 1)
+ lungetc((unsigned char)*--p);
+ c = (unsigned char)*--p;
+ if (c == '-')
+ return (c);
+ }
+ }
+
+#define allowed_in_string(x) \
+ (isalnum(x) || (ispunct(x) && x != '(' && x != ')' && \
+ x != '{' && x != '}' && x != '<' && x != '>' && \
+ x != '!' && x != '=' && x != '/' && x != '#' && \
+ x != ','))
+
+ if (isalnum(c) || c == ':' || c == '_' || c == '*') {
+ do {
+ *p++ = c;
+ if ((size_t)(p-buf) >= sizeof(buf)) {
+ yyerror("string too long");
+ return (findeol());
+ }
+ } while ((c = lgetc(0)) != EOF && (allowed_in_string(c)));
+ lungetc(c);
+ *p = '\0';
+ if ((token = lookup(buf)) == STRING)
+ if ((yylval.v.string = strdup(buf)) == NULL)
+ fatal("yylex: strdup");
+ return (token);
+ }
+ if (c == '\n') {
+ yylval.lineno = file->lineno;
+ file->lineno++;
+ }
+ if (c == EOF)
+ return (0);
+ return (c);
+}
+
+struct file *
+pushfile(const char *name, int secret)
+{
+ struct file *nfile;
+
+ nfile = xcalloc(1, sizeof(*nfile));
+ nfile->name = xstrdup(name);
+ if ((nfile->stream = fopen(nfile->name, "r")) == NULL) {
+ log_warn("%s", nfile->name);
+ free(nfile->name);
+ free(nfile);
+ return (NULL);
+ }
+ if (secret &&
+ conf_check_secrecy(fileno(nfile->stream), nfile->name,
+ getuid()) == -1) {
+ fclose(nfile->stream);
+ free(nfile->name);
+ free(nfile);
+ return (NULL);
+ }
+ /*
+ * Top-level file starts at line 1; an included file starts at 0 because
+ * the include rule injects a '\n' (lungetc) that yylex counts as the
+ * first line. `file` is the current top-of-stack (NULL for the top file).
+ */
+ nfile->lineno = (file == NULL) ? 1 : 0;
+ nfile->secret = secret;
+ nfile->ungetsize = 16;
+ nfile->ungetbuf = xmalloc(nfile->ungetsize);
+ nfile->prev = file;
+ return (nfile);
+}
+
+int
+popfile(void)
+{
+ struct file *prev;
+
+ prev = file->prev;
+ if (prev != NULL)
+ prev->errors += file->errors;
+
+ fclose(file->stream);
+ free(file->name);
+ free(file->ungetbuf);
+ free(file);
+ file = prev;
+ return (file ? 0 : EOF);
+}
+
+int
+symset(const char *nam, const char *val, int persist)
+{
+ struct sym *sym;
+
+ TAILQ_FOREACH(sym, &symhead, entry) {
+ if (strcmp(nam, sym->nam) == 0)
+ break;
+ }
+ if (sym != NULL) {
+ if (sym->persist)
+ return (0);
+ free(sym->nam);
+ free(sym->val);
+ TAILQ_REMOVE(&symhead, sym, entry);
+ free(sym);
+ }
+ sym = xcalloc(1, sizeof(*sym));
+ sym->nam = xstrdup(nam);
+ sym->val = xstrdup(val);
+ sym->persist = persist;
+ TAILQ_INSERT_TAIL(&symhead, sym, entry);
+ return (0);
+}
+
+char *
+symget(const char *nam)
+{
+ struct sym *sym;
+
+ TAILQ_FOREACH(sym, &symhead, entry)
+ if (strcmp(nam, sym->nam) == 0)
+ return (sym->val);
+ return (NULL);
+}
+
+int
+conf_parse_file(struct conf *xconf, const char *filename, int secret)
+{
+ struct sym *sym;
+ int errors;
+
+ conf = xconf;
+ cur = &conf->global;
+ file = NULL; /* empty include stack: the top file starts at line 1 */
+
+ if ((file = pushfile(filename, secret)) == NULL)
+ return (-1);
+ topfile = file;
+
+ yyparse();
+ errors = file->errors;
+ popfile();
+
+ /* Drop all macros so a later parse starts clean. */
+ while ((sym = TAILQ_FIRST(&symhead)) != NULL) {
+ TAILQ_REMOVE(&symhead, sym, entry);
+ free(sym->nam);
+ free(sym->val);
+ free(sym);
+ }
+
+ return (errors ? -1 : 0);
+}
blob - /dev/null
blob + 1e19e4b5d8dff756c399b3f6b3c81491309c4e69 (mode 644)
--- /dev/null
+++ src/common/provider.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_PROVIDER_H
+#define FUGU_PROVIDER_H
+
+#include <sys/types.h>
+
+/*
+ * The provider abstraction. fugu reaches a model endpoint through one of two
+ * wire protocols: the Anthropic Messages API, or the OpenAI chat/completions
+ * API that most other vendors and local servers (OpenRouter, Groq, Together,
+ * DeepSeek, Ollama, llama.cpp, ...) emulate. Each provider module is two pure
+ * halves -- a request builder and a streaming-response decoder. The decoder
+ * turns that vendor's event vocabulary into this single set of semantic
+ * callbacks, so the net worker's agent loop, its conversation projection, and
+ * the journal stay provider-agnostic (always in the canonical Anthropic shape).
+ *
+ * The per-provider dispatch table (union llm_stream_storage, struct
+ * llm_provider_ops) lives one layer up in provider_ops.h to avoid an include
+ * cycle with the adapter headers, which need struct llm_cbs by value.
+ */
+
+enum llm_provider {
+ LLM_ANTHROPIC = 0,
+ LLM_OPENAI
+};
+
+/* Semantic callbacks fired while interpreting a streamed response. */
+struct llm_cbs {
+ void (*on_text)(const char *text, size_t len, void *arg);
+ void (*on_tool_call)(const char *id, const char *name,
+ const char *input_json, size_t len, void *arg);
+ void (*on_usage)(long long input_tokens, long long output_tokens,
+ void *arg);
+ void (*on_stop)(const char *stop_reason, void *arg);
+ void (*on_error)(const char *type, const char *message, void *arg);
+ void (*on_done)(void *arg);
+};
+
+#endif /* FUGU_PROVIDER_H */
blob - /dev/null
blob + 21d621f09d737dd6031854eff4877558efcfebde (mode 644)
--- /dev/null
+++ src/common/provider_ops.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * provider_ops: the provider-dispatch seam. Sits one layer above provider.h
+ * (which defines the semantic callback surface that every adapter targets) and
+ * pulls in both adapter headers so the per-provider stream structs are complete
+ * types at the point union llm_stream_storage is defined.
+ *
+ * Lives in its own header because adapter headers must include provider.h for
+ * struct llm_cbs; routing the union + ops through that same header would form
+ * an include cycle that the include guards can't unwind. Callers that only
+ * need the callback surface include provider.h; the net worker, which has to
+ * dispatch concrete adapter ops, includes provider_ops.h.
+ */
+
+#ifndef FUGU_PROVIDER_OPS_H
+#define FUGU_PROVIDER_OPS_H
+
+#include <sys/types.h>
+
+#include "anthropic.h"
+#include "openai.h"
+#include "provider.h"
+
+/*
+ * Stack storage for either provider's streaming decoder; the union member used
+ * is selected by the active provider's ops table. Lets the net worker keep
+ * one automatic-storage decoder slot per call rather than two named locals.
+ */
+union llm_stream_storage {
+ struct anthropic_stream a;
+ struct openai_stream o;
+};
+
+/* Forward decls -- defined in the headers indicated. */
+struct http_header; /* http.h */
+struct buf; /* buf.h */
+
+/*
+ * Per-provider dispatch. Each adapter exposes a const ops table; the active
+ * provider's ops is resolved at config-apply time and stored on the
+ * struct net_provider (added in a later step).
+ *
+ * prep_request fills body with the streaming request body, populates the
+ * header array (up to hmax slots, *nh on return), stashes any synthesised
+ * auth-header value in authval (so the caller can buf_freezero it), and
+ * sets *path to the default request path for this provider. The caller
+ * is free to override *path afterward. Broken-out scalars (key, model)
+ * rather than a struct net_provider * keep this header decoupled from
+ * net_run.h while the seam is moving.
+ * stream_init / stream_event / stream_clear drive the SSE decoder via the
+ * union; the active union member is implicit in the ops table chosen.
+ * models_path returns the default chat-endpoint path constant for this
+ * provider; the caller derives the models-list endpoint from it.
+ */
+struct llm_provider_ops {
+ void (*prep_request)(struct buf *body, struct http_header *h,
+ int hmax, int *nh, struct buf *authval,
+ const char **path, const char *key, const char *model,
+ int max_tokens, const char *system,
+ const char *conv, const char *tools);
+ void (*stream_init)(union llm_stream_storage *,
+ const struct llm_cbs *, void *arg);
+ void (*stream_event)(union llm_stream_storage *,
+ const char *event, const char *data, size_t len);
+ void (*stream_clear)(union llm_stream_storage *);
+ const char *(*models_path)(void);
+};
+
+#endif /* FUGU_PROVIDER_OPS_H */
blob - /dev/null
blob + 5316a9b77149eb24e61e1cf7ab10b614cdbfe811 (mode 644)
--- /dev/null
+++ src/common/sse.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <string.h>
+
+#include "buf.h"
+#include "sse.h"
+
+#define SSE_MAXLINE_DEFAULT (1U << 20) /* 1 MiB per line */
+#define SSE_MAXTOTAL_DEFAULT (8U << 20) /* 8 MiB per event payload */
+
+static int sse_line(struct sse_parser *, const char *, size_t);
+static void sse_dispatch(struct sse_parser *);
+
+void
+sse_init(struct sse_parser *p, sse_event_cb cb, void *arg)
+{
+ memset(p, 0, sizeof(*p));
+ buf_init(&p->line);
+ buf_init(&p->event);
+ buf_init(&p->data);
+ p->cb = cb;
+ p->arg = arg;
+ p->maxline = SSE_MAXLINE_DEFAULT;
+ p->maxtotal = SSE_MAXTOTAL_DEFAULT;
+}
+
+void
+sse_clear(struct sse_parser *p)
+{
+ buf_free(&p->line);
+ buf_free(&p->event);
+ buf_free(&p->data);
+ memset(p, 0, sizeof(*p));
+}
+
+/*
+ * Feed bytes. Returns 0, or -1 if a single line exceeds maxline (a defense
+ * against a peer that streams an unbounded line to exhaust memory).
+ */
+int
+sse_push(struct sse_parser *p, const void *vp, size_t len)
+{
+ const unsigned char *s = vp;
+ size_t i;
+
+ for (i = 0; i < len; i++) {
+ unsigned char ch = s[i];
+
+ if (ch == '\n') {
+ if (p->after_cr) { /* the LF of a CRLF: swallow */
+ p->after_cr = 0;
+ continue;
+ }
+ if (sse_line(p, p->line.data, p->line.len) == -1)
+ return (-1);
+ buf_reset(&p->line);
+ } else if (ch == '\r') {
+ p->after_cr = 1;
+ if (sse_line(p, p->line.data, p->line.len) == -1)
+ return (-1);
+ buf_reset(&p->line);
+ } else {
+ p->after_cr = 0;
+ if (p->line.len >= p->maxline)
+ return (-1);
+ buf_putc(&p->line, ch);
+ }
+ }
+ return (0);
+}
+
+/*
+ * Process one complete line (terminator already consumed). Returns 0, or -1
+ * if the accumulated event payload would exceed maxtotal (a hostile stream that
+ * sends data lines without ever terminating the event).
+ */
+static int
+sse_line(struct sse_parser *p, const char *line, size_t len)
+{
+ const char *value;
+ size_t i, vlen;
+
+ if (len == 0) { /* blank line dispatches the event */
+ sse_dispatch(p);
+ return (0);
+ }
+ if (line[0] == ':') /* comment */
+ return (0);
+
+ for (i = 0; i < len && line[i] != ':'; i++)
+ ;
+ if (i < len) {
+ value = line + i + 1;
+ vlen = len - (i + 1);
+ if (vlen > 0 && value[0] == ' ') { /* strip one leading space */
+ value++;
+ vlen--;
+ }
+ } else { /* a field name with no colon: empty value */
+ value = line + len;
+ vlen = 0;
+ }
+
+ if (i == 5 && memcmp(line, "event", 5) == 0) {
+ buf_reset(&p->event);
+ buf_append(&p->event, value, vlen);
+ } else if (i == 4 && memcmp(line, "data", 4) == 0) {
+ if (p->data.len + vlen + 1 > p->maxtotal)
+ return (-1); /* event never terminated; give up */
+ buf_append(&p->data, value, vlen);
+ buf_putc(&p->data, '\n');
+ p->have_data = 1;
+ }
+ /* "id", "retry", and unknown fields are accepted and ignored. */
+ return (0);
+}
+
+static void
+sse_dispatch(struct sse_parser *p)
+{
+ const char *ev;
+
+ /* The accumulated data carries a trailing '\n'; the spec removes it. */
+ if (p->data.len > 0 && p->data.data[p->data.len - 1] == '\n')
+ p->data.len--;
+
+ if (p->have_data) {
+ if (p->event.len > 0) {
+ buf_terminate(&p->event); /* NUL-terminate, uncounted */
+ ev = p->event.data;
+ } else
+ ev = "message";
+
+ buf_terminate(&p->data); /* NUL-terminate, uncounted */
+
+ p->cb(ev, p->data.data, p->data.len, p->arg);
+ }
+
+ buf_reset(&p->event);
+ buf_reset(&p->data);
+ p->have_data = 0;
+}
blob - /dev/null
blob + fd9f8b912bf4fdd8b074db7f6e4542d8d6ba199b (mode 644)
--- /dev/null
+++ src/common/sse.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * sse: an incremental parser for the Server-Sent Events wire format
+ * (text/event-stream), the framing the Anthropic Messages API streams over.
+ *
+ * Bytes arrive from the network in arbitrary-sized pieces, so this is a push
+ * parser: feed it whatever you read with sse_push(); it buffers partial lines
+ * across calls and invokes the callback once per dispatched event (a blank
+ * line). It implements the parts of the EventSource spec we need: CR, LF, and
+ * CRLF line endings, ':' comment lines, the one-optional-space-after-colon
+ * rule, multiple "data:" lines joined by '\n', and the default "message" event
+ * type. "id" and "retry" fields are accepted and ignored.
+ */
+
+#ifndef FUGU_SSE_H
+#define FUGU_SSE_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+/*
+ * Invoked once per event. event is the NUL-terminated event type ("message"
+ * by default); data is the NUL-terminated payload of datalen bytes (the
+ * trailing newline already removed). Both pointers are owned by the parser and
+ * valid only for the duration of the call; copy anything you need to keep.
+ */
+typedef void (*sse_event_cb)(const char *event, const char *data,
+ size_t datalen, void *arg);
+
+struct sse_parser {
+ struct buf line; /* current line, until a terminator */
+ struct buf event; /* current "event:" field */
+ struct buf data; /* accumulated "data:" fields */
+ int have_data; /* a data field was seen this event */
+ int after_cr; /* last byte was CR; swallow a LF */
+ size_t maxline; /* per-line cap (hostile-stream guard) */
+ size_t maxtotal; /* cap on one event's accumulated data */
+ sse_event_cb cb;
+ void *arg;
+};
+
+void sse_init(struct sse_parser *, sse_event_cb, void *);
+void sse_clear(struct sse_parser *);
+int sse_push(struct sse_parser *, const void *, size_t); /* 0 / -1 */
+
+#endif /* FUGU_SSE_H */
blob - /dev/null
blob + feb0971d4227b19d120af44c78733fd2d680e204 (mode 644)
--- /dev/null
+++ src/common/tlsconn.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * A thin blocking libtls client. See tlsconn.h for the contract; this
+ * file owns the implementation that was previously inlined into the net
+ * and fetch workers.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "tlsconn.h"
+#include "util.h"
+
+/*
+ * Snapshot a libtls error into a freshly allocated string so the caller can
+ * read it after tls_free() has released the context-owned buffer. tls_error()
+ * can return NULL when no error has been recorded -- substitute a placeholder
+ * so callers can rely on *errstr being non-NULL and freeable on the -1 path.
+ *
+ * Contract for this file: tls_error() is called HERE and nowhere else. Every
+ * function that surfaces a libtls failure to a caller goes through
+ * tlsconn_dup_error first, so the borrowed libtls error buffer never escapes
+ * the module. Any new code that captures tls_error() inline must be migrated
+ * to this helper or it will reintroduce the use-after-free class the audit
+ * closed (the borrowed pointer becomes dangling after the next tls_free /
+ * tls_close).
+ */
+static char *
+tlsconn_dup_error(struct tls *ctx)
+{
+ const char *e = tls_error(ctx);
+
+ return (xstrdup(e != NULL ? e : "(unknown)"));
+}
+
+struct tls_config *
+tlsconn_client_config(const char *cafile)
+{
+ struct tls_config *cfg;
+ uint8_t *ca;
+ size_t calen;
+
+ if ((cfg = tls_config_new()) == NULL)
+ return (NULL);
+ if (cafile != NULL) {
+ if ((ca = tls_load_file(cafile, &calen, NULL)) == NULL) {
+ tls_config_free(cfg);
+ return (NULL);
+ }
+ /* tls_config_set_ca_mem copies the data, so we free our copy. */
+ if (tls_config_set_ca_mem(cfg, ca, calen) == -1) {
+ free(ca);
+ tls_config_free(cfg);
+ return (NULL);
+ }
+ free(ca);
+ }
+ return (cfg);
+}
+
+int
+tlsconn_connect(struct tlsconn *c, struct tls_config *cfg, const char *host,
+ const char *port, char **errstr)
+{
+ memset(c, 0, sizeof(*c));
+ c->fd = -1;
+ if ((c->ctx = tls_client()) == NULL) {
+ *errstr = xstrdup("tls_client failed");
+ return (-1);
+ }
+ if (tls_configure(c->ctx, cfg) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ if (tls_connect(c->ctx, host, port) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ if (tlsconn_do_handshake(c->ctx) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ return (0);
+fail:
+ /* *errstr was duplicated above, so it survives tls_free(). */
+ tls_free(c->ctx);
+ c->ctx = NULL;
+ return (-1);
+}
+
+int
+tlsconn_attach(struct tlsconn *c, struct tls_config *cfg, int fd,
+ const char *servername, char **errstr)
+{
+ memset(c, 0, sizeof(*c));
+ /*
+ * c->fd stays -1 until handshake succeeds; if any step below fails the
+ * caller still owns `fd` and is responsible for close()ing it. This
+ * is asymmetric with tlsconn_connect (which dials the socket itself
+ * and so owns it on failure too), but fetch's dial path needs to keep
+ * fd lifetime under its own SSRF/timeout-aware error reporting.
+ */
+ c->fd = -1;
+ if ((c->ctx = tls_client()) == NULL) {
+ *errstr = xstrdup("tls_client failed");
+ return (-1);
+ }
+ if (tls_configure(c->ctx, cfg) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ if (tls_connect_socket(c->ctx, fd, servername) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ if (tlsconn_do_handshake(c->ctx) == -1) {
+ *errstr = tlsconn_dup_error(c->ctx);
+ goto fail;
+ }
+ c->fd = fd; /* handshake done -- we own it now */
+ return (0);
+fail:
+ tls_free(c->ctx);
+ c->ctx = NULL;
+ return (-1);
+}
+
+ssize_t
+tlsconn_read(struct tlsconn *c, void *buf, size_t len)
+{
+ ssize_t r;
+
+ do {
+ r = tls_read(c->ctx, buf, len);
+ } while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+ return (r);
+}
+
+ssize_t
+tlsconn_write(struct tlsconn *c, const void *buf, size_t len)
+{
+ ssize_t r;
+
+ do {
+ r = tls_write(c->ctx, buf, len);
+ } while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+ return (r);
+}
+
+int
+tlsconn_write_all(struct tlsconn *c, const void *buf, size_t len)
+{
+ const uint8_t *p = buf;
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = tlsconn_write(c, p + off, len - off);
+ if (n == -1)
+ return (-1);
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+void
+tlsconn_close(struct tlsconn *c)
+{
+ if (c->ctx != NULL) {
+ tls_close(c->ctx);
+ tls_free(c->ctx);
+ c->ctx = NULL;
+ }
+ if (c->fd >= 0) {
+ close(c->fd);
+ c->fd = -1;
+ }
+}
+
+int
+tlsconn_do_handshake(struct tls *ctx)
+{
+ int r;
+
+ do {
+ r = tls_handshake(ctx);
+ } while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+ return (r);
+}
blob - /dev/null
blob + 46b96549e0e26dad8a24ef41d081f3479b58e55a (mode 644)
--- /dev/null
+++ src/common/tlsconn.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_TLSCONN_H
+#define FUGU_TLSCONN_H
+
+/*
+ * A thin blocking libtls client used by the workers for HTTPS endpoints.
+ * The CA bundle is loaded into memory when the config is built, so the
+ * connect path itself needs no rpath -- the worker can pledge "stdio inet
+ * dns" before connecting. tls_read/tls_write WANT_POLL* returns are
+ * retried internally, so callers see ordinary blocking read/write
+ * semantics.
+ */
+
+#include <sys/types.h>
+
+#include <tls.h>
+
+struct tlsconn {
+ struct tls *ctx;
+ int fd; /* owned socket (connect_socket); -1 otherwise */
+};
+
+struct tls_config *tlsconn_client_config(const char *cafile);
+/*
+ * Open a TLS client connection to host:port. Returns 0 on success. On
+ * failure returns -1 and stores a heap-allocated, NUL-terminated error
+ * description in *errstr; the caller must free() it. The string is owned by
+ * the caller because the underlying libtls context-owned buffer is released
+ * before this function returns -- reading tls_error()'s pointer afterwards
+ * would be a use-after-free. *errstr is left untouched on success.
+ */
+int tlsconn_connect(struct tlsconn *, struct tls_config *,
+ const char *host, const char *port, char **errstr);
+/*
+ * Attach TLS to an already-connected socket: the fetch worker dials with
+ * its own SSRF guard and wall-clock deadline, then hands the open fd here
+ * for the TLS handshake. servername drives the SNI extension and
+ * peer-name verification (use the URL host, not the dial-resolved IP).
+ *
+ * Ownership: on SUCCESS the struct tlsconn takes the fd into c->fd, and
+ * tlsconn_close will close it. On FAILURE the fd is NOT touched -- the
+ * caller's dial-time fd is its responsibility to close. Same errstr
+ * contract as tlsconn_connect: -1 returns leave *errstr pointing at a
+ * heap-allocated description that the caller free()s.
+ */
+int tlsconn_attach(struct tlsconn *, struct tls_config *, int fd,
+ const char *servername, char **errstr);
+ssize_t tlsconn_read(struct tlsconn *, void *, size_t);
+ssize_t tlsconn_write(struct tlsconn *, const void *, size_t);
+int tlsconn_write_all(struct tlsconn *, const void *, size_t);
+void tlsconn_close(struct tlsconn *);
+int tlsconn_do_handshake(struct tls *);
+
+#endif /* FUGU_TLSCONN_H */
blob - /dev/null
blob + adc134d939f793d01793c30c6c7a6b52fce0105d (mode 644)
--- /dev/null
+++ src/common/util.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "log.h"
+#include "util.h"
+
+void *
+xmalloc(size_t size)
+{
+ void *p;
+
+ if (size == 0)
+ fatalx("xmalloc: zero size");
+ if ((p = malloc(size)) == NULL)
+ fatal("malloc");
+ return (p);
+}
+
+void *
+xcalloc(size_t nmemb, size_t size)
+{
+ void *p;
+
+ if (nmemb == 0 || size == 0)
+ fatalx("xcalloc: zero size");
+ if (SIZE_MAX / nmemb < size)
+ fatalx("xcalloc: nmemb * size > SIZE_MAX");
+ if ((p = calloc(nmemb, size)) == NULL)
+ fatal("calloc");
+ return (p);
+}
+
+void *
+xreallocarray(void *o, size_t nmemb, size_t size)
+{
+ void *p;
+
+ if (nmemb == 0 || size == 0)
+ fatalx("xreallocarray: zero size");
+ if ((p = reallocarray(o, nmemb, size)) == NULL)
+ fatal("reallocarray");
+ return (p);
+}
+
+char *
+xstrdup(const char *s)
+{
+ char *p;
+
+ if ((p = strdup(s)) == NULL)
+ fatal("strdup");
+ return (p);
+}
+
+void
+drop_privgroup(void)
+{
+ gid_t gid = getgid();
+
+ /* Set real, effective, and saved gid so the _fugu group cannot return. */
+ if (setresgid(gid, gid, gid) == -1)
+ fatal("setresgid");
+}
blob - /dev/null
blob + 6a1ee03b1ba420a6c190a282679b1a6d1dbdd866 (mode 644)
--- /dev/null
+++ src/common/util.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_UTIL_H
+#define FUGU_UTIL_H
+
+#include <sys/types.h>
+
+/* Allocation wrappers that call fatal() on failure; never return NULL. */
+void *xmalloc(size_t);
+void *xcalloc(size_t, size_t);
+void *xreallocarray(void *, size_t, size_t);
+char *xstrdup(const char *);
+
+/*
+ * Irreversibly drop to the real group id, shedding any setgid group inherited
+ * at exec. The parent is installed setgid _fugu solely to read the root-owned
+ * /etc/fugu.conf; once that read is done (and in every worker from the outset)
+ * the privilege must go so neither the process nor anything it exec()s can reach
+ * _fugu-group files. Calls fatal() if the drop fails.
+ */
+void drop_privgroup(void);
+
+#endif /* FUGU_UTIL_H */
blob - /dev/null
blob + 01c0c1f79da1a3aba0949b1fb727a73ba927b80c (mode 644)
--- /dev/null
+++ src/exec/Makefile
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG= fugu-exec
+SRCS+= exec.c exec_run.c tools_file.c tools_shell.c
+BINDIR= ${PREFIX}/libexec/fugu
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + c9a922195661aa8c144de26b7f5ddeb4c6affcca (mode 644)
--- /dev/null
+++ src/exec/exec.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-exec: the tool worker (${PREFIX}/libexec/fugu/fugu-exec).
+ *
+ * Runs the model's file tools (read/write/edit) and base-tool wrappers
+ * (shell/grep/find/ls) against the project cwd, inside a unveil(2)-confined
+ * sandbox with NO network for the worker or its subprocesses (pledge "stdio
+ * rpath wpath cpath fattr proc exec", inet-excluding execpromises; -N relaxes
+ * the latter for subprocesses only). Holds no secret. The imsg channel
+ * arrives on fd 3; not intended to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+#define FUGU_EXEC_IMSG_FD 3
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgproto ip;
+ struct stat st;
+ extern char *__progname;
+ int ch, debug = 0, verbose = 0, subproc_net = 0;
+
+ while ((ch = getopt(argc, argv, "dvN")) != -1) {
+ switch (ch) {
+ case 'd':
+ debug = 1;
+ break;
+ case 'v':
+ verbose++;
+ break;
+ case 'N': /* allow_subprocess_net (parent) */
+ subproc_net = 1;
+ break;
+ default:
+ break;
+ }
+ }
+
+ log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+ signal(SIGPIPE, SIG_IGN);
+ drop_privgroup(); /* never need the parent's setgid _fugu group */
+
+ if (fstat(FUGU_EXEC_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+ fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+ "directly\n", __progname);
+ return (1);
+ }
+ if (ip_init(&ip, FUGU_EXEC_IMSG_FD) == -1)
+ fatal("ip_init");
+
+ exec_sandbox(subproc_net);
+ return (exec_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 9ff8465a06432d13ebfaa06b153ab2c42e254855 (mode 644)
--- /dev/null
+++ src/exec/exec_run.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The exec worker: runs the model's file tools inside a cwd-confined sandbox
+ * with no network. It receives M_TOOL_REQUEST {id,name,input}, dispatches by
+ * name, and replies M_TOOL_RESULT {id,content,is_error} to net via the parent.
+ */
+
+#include <sys/types.h>
+
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "tools.h"
+#include "util.h"
+
+struct execstate {
+ struct imsgproto *ip;
+ int allow_write;
+};
+
+static void handle_tool(struct execstate *, const void *, size_t);
+static void apply_config(struct execstate *, const void *, size_t);
+static void emit_result(struct execstate *, const char *, struct buf *, int);
+static char *obj_getstr(struct json_doc *, int, const char *);
+static void unveil_or_skip(const char *, const char *);
+
+/* unveil(2) a path, tolerating ENOENT so optional dirs need not exist. */
+static void
+unveil_or_skip(const char *path, const char *perms)
+{
+ if (unveil(path, perms) == -1 && errno != ENOENT)
+ fatal("unveil %s", path);
+}
+
+void
+exec_sandbox(int allow_subprocess_net)
+{
+ /* The project tree: the model's file and shell tools work here. */
+ if (unveil(".", "rwc") == -1)
+ fatal("unveil .");
+
+ /* Base tools (ksh/grep/find/ls) plus the libraries they load. */
+ unveil_or_skip("/bin", "rx");
+ unveil_or_skip("/sbin", "rx");
+ unveil_or_skip("/usr/bin", "rx");
+ unveil_or_skip("/usr/sbin", "rx");
+ unveil_or_skip("/usr/local/bin", "rx");
+ unveil_or_skip("/usr/lib", "rx");
+ unveil_or_skip("/usr/libexec", "rx");
+ unveil_or_skip("/usr/local/lib", "rx");
+ unveil_or_skip("/var/run/ld.so.hints", "r");
+ unveil_or_skip("/dev/null", "rw");
+
+ /*
+ * Subprocess networking is opt-in. When enabled, the execpromises below
+ * grant children "inet dns"; they also need the resolver and trust store
+ * visible. The exec worker itself never gets inet.
+ */
+ if (allow_subprocess_net) {
+ unveil_or_skip("/etc/resolv.conf", "r");
+ unveil_or_skip("/etc/hosts", "r");
+ unveil_or_skip("/etc/services", "r");
+ unveil_or_skip("/etc/ssl", "r");
+ }
+
+ if (unveil(NULL, NULL) == -1) /* lock the unveil list */
+ fatal("unveil lock");
+
+ /*
+ * Our own promises stay tight: proc+exec to run the base tools, fattr for
+ * the atomic writer's fchmod(2), and NO inet. The execpromises are the
+ * ceiling every subprocess (and its descendants) runs under: a superset of
+ * what ksh/ls/find/grep pledge for themselves (they need getpw, tty, and
+ * flock), but WITHOUT "inet dns" -- so a subprocess that tries to touch the
+ * network has its own pledge() denied or is killed. "inet dns" is added to
+ * the ceiling only when allow_subprocess_net. The unveil confinement above
+ * is inherited across execve(2) either way.
+ */
+ if (pledge("stdio rpath wpath cpath fattr proc exec",
+ allow_subprocess_net ?
+ "stdio rpath wpath cpath fattr chown getpw proc exec tty flock "
+ "inet dns" :
+ "stdio rpath wpath cpath fattr chown getpw proc exec tty flock")
+ == -1)
+ fatal("pledge");
+}
+
+int
+exec_loop(struct imsgproto *ip)
+{
+ struct execstate es;
+ struct fugu_msg m;
+ int r;
+
+ memset(&es, 0, sizeof(es));
+ es.ip = ip;
+ es.allow_write = 1; /* confined to cwd by unveil; config can disable */
+
+ for (;;) {
+ r = ip_get(ip, &m);
+ if (r == -1) {
+ log_warnx("exec: imsg protocol error");
+ break;
+ }
+ if (r == 0) {
+ if (ip_read(ip) <= 0)
+ break;
+ continue;
+ }
+ switch (m.type) {
+ case M_CONFIG:
+ apply_config(&es, m.data, m.len);
+ break;
+ case M_TOOL_REQUEST:
+ handle_tool(&es, m.data, m.len);
+ break;
+ case M_SHUTDOWN:
+ ip_msg_free(&m);
+ goto out;
+ default:
+ break;
+ }
+ ip_msg_free(&m);
+ }
+out:
+ return (0);
+}
+
+static void
+apply_config(struct execstate *es, const void *data, size_t len)
+{
+ struct json_doc d;
+ int t;
+
+ if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ return;
+ }
+ if ((t = json_obj_get(&d, 0, "allow_write")) >= 0)
+ es->allow_write = json_tok_streq(&d, t, "true");
+ json_doc_free(&d);
+}
+
+static void
+handle_tool(struct execstate *es, const void *data, size_t len)
+{
+ struct json_doc d;
+ struct buf out;
+ char *id = NULL, *name = NULL, *path = NULL;
+ int input, is_error = 1;
+
+ buf_init(&out);
+
+ if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ buf_puts_cstr(&out, "malformed tool request");
+ emit_result(es, "", &out, 1);
+ json_doc_free(&d);
+ buf_free(&out);
+ return;
+ }
+
+ id = obj_getstr(&d, 0, "id");
+ name = obj_getstr(&d, 0, "name");
+ input = json_obj_get(&d, 0, "input");
+
+ if (name == NULL) {
+ buf_puts_cstr(&out, "missing tool name");
+ } else if (strcmp(name, "read") == 0) {
+ if ((path = obj_getstr(&d, input, "path")) == NULL)
+ buf_puts_cstr(&out, "read: missing 'path'");
+ else
+ is_error = (tool_read(path, &out) != 0);
+ } else if (strcmp(name, "write") == 0) {
+ char *content = obj_getstr(&d, input, "content");
+
+ if ((path = obj_getstr(&d, input, "path")) == NULL ||
+ content == NULL)
+ buf_puts_cstr(&out, "write: missing 'path' or 'content'");
+ else
+ is_error = (tool_write(es->allow_write, path, content,
+ strlen(content), &out) != 0);
+ free(content);
+ } else if (strcmp(name, "edit") == 0) {
+ char *olds = obj_getstr(&d, input, "old_string");
+ char *news = obj_getstr(&d, input, "new_string");
+
+ if ((path = obj_getstr(&d, input, "path")) == NULL ||
+ olds == NULL || news == NULL)
+ buf_puts_cstr(&out, "edit: missing 'path', 'old_string' or "
+ "'new_string'");
+ else
+ is_error = (tool_edit(es->allow_write, path, olds, news,
+ &out) != 0);
+ free(olds);
+ free(news);
+ } else if (strcmp(name, "shell") == 0) {
+ char *cmd = obj_getstr(&d, input, "command");
+
+ if (cmd == NULL)
+ buf_puts_cstr(&out, "shell: missing 'command'");
+ else
+ is_error = (tool_shell(cmd, &out) != 0);
+ free(cmd);
+ } else if (strcmp(name, "grep") == 0) {
+ char *pat = obj_getstr(&d, input, "pattern");
+
+ path = obj_getstr(&d, input, "path");
+ if (pat == NULL)
+ buf_puts_cstr(&out, "grep: missing 'pattern'");
+ else
+ is_error = (tool_grep(pat, path, &out) != 0);
+ free(pat);
+ } else if (strcmp(name, "find") == 0) {
+ char *nm = obj_getstr(&d, input, "name");
+
+ path = obj_getstr(&d, input, "path");
+ is_error = (tool_find(path, nm, &out) != 0);
+ free(nm);
+ } else if (strcmp(name, "ls") == 0) {
+ path = obj_getstr(&d, input, "path");
+ is_error = (tool_ls(path, &out) != 0);
+ } else {
+ buf_printf(&out, "unknown tool: %s", name);
+ }
+
+ emit_result(es, id != NULL ? id : "", &out, is_error);
+ free(id);
+ free(name);
+ free(path);
+ json_doc_free(&d);
+ buf_free(&out);
+}
+
+static void
+emit_result(struct execstate *es, const char *id, struct buf *content,
+ int is_error)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_key(&w, "content");
+ json_string_n(&w, content->len > 0 ? content->data : "", content->len);
+ json_kv_bool(&w, "is_error", is_error);
+ json_object_end(&w);
+
+ if (ip_compose(es->ip, M_TOOL_RESULT, EP_NET, EP_EXEC, b.data,
+ b.len) == -1 || ip_flush(es->ip) == -1)
+ fatalx("exec: failed to send result");
+ buf_free(&b);
+}
+
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+ int t;
+
+ if (obj < 0)
+ return (NULL);
+ if ((t = json_obj_get(d, obj, key)) < 0 ||
+ json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
blob - /dev/null
blob + e0981fe53d424a14ec69cc03164cf137a9aa9c45 (mode 644)
--- /dev/null
+++ src/exec/exec_run.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_EXEC_RUN_H
+#define FUGU_EXEC_RUN_H
+
+#include "imsgproto.h"
+
+/*
+ * Confine the worker to the current working directory and the base tools it
+ * spawns: unveil "." rwc, unveil the system
+ * binary/library dirs rx, then pledge "stdio rpath wpath cpath fattr proc exec"
+ * (no inet) with execpromises that exclude "inet dns" -- so the worker and every
+ * subprocess it runs are network-blind -- unless allow_subprocess_net is set, in
+ * which case subprocesses (only) gain "inet dns". Call after any chdir(2) and
+ * before serving requests.
+ */
+void exec_sandbox(int allow_subprocess_net);
+
+/*
+ * Service tool requests on an imsg channel to the parent until M_SHUTDOWN or
+ * the channel closes. Returns 0 on a clean exit.
+ */
+int exec_loop(struct imsgproto *);
+
+#endif /* FUGU_EXEC_RUN_H */
blob - /dev/null
blob + 4b57313d38bde2bf897387542c08956fea43ac46 (mode 644)
--- /dev/null
+++ src/exec/tools.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * tools: the native file tools run by the exec worker. Each writes its result
+ * (file content, a confirmation, or an error message) into out and returns 0 on
+ * success or -1 on error. Path access is confined by the worker's unveil(2);
+ * these functions add no path policy of their own beyond the allow_write gate.
+ */
+
+#ifndef FUGU_TOOLS_H
+#define FUGU_TOOLS_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define TOOLS_MAXFILE (1024 * 1024) /* cap a read/edit at 1 MiB */
+#define TOOLS_MAXOUTPUT (256 * 1024) /* cap captured subprocess output */
+#define TOOLS_EXEC_TIMEOUT 30 /* kill a runaway child after N s */
+
+/* Native file tools (tools_file.c). */
+int tool_read(const char *path, struct buf *out);
+int tool_write(int allow_write, const char *path, const char *content,
+ size_t clen, struct buf *out);
+int tool_edit(int allow_write, const char *path, const char *old_string,
+ const char *new_string, struct buf *out);
+
+/*
+ * Subprocess tools (tools_shell.c): each runs a base program with its output
+ * (combined stdout+stderr) captured into out and returns 0, or -1 if the child
+ * could not be spawned or had to be killed for exceeding the time limit. A
+ * non-zero exit status is reported in out but is not itself an error. The
+ * children run under the exec worker's execpromises (no network unless
+ * allow_subprocess_net), with cwd confinement inherited from unveil(2).
+ */
+int tool_shell(const char *command, struct buf *out);
+int tool_grep(const char *pattern, const char *path, struct buf *out);
+int tool_find(const char *path, const char *name, struct buf *out);
+int tool_ls(const char *path, struct buf *out);
+
+#endif /* FUGU_TOOLS_H */
blob - /dev/null
blob + b5ee0970605686bca81afe150c155e4e29f31f2c (mode 644)
--- /dev/null
+++ src/exec/tools_file.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "tools.h"
+
+static int write_all(int, const char *, size_t);
+static int slurp(const char *, struct buf *);
+static int write_atomic(const char *, const char *, size_t, struct buf *);
+
+int
+tool_read(const char *path, struct buf *out)
+{
+ if (slurp(path, out) == -1) {
+ int e = errno;
+
+ buf_reset(out);
+ buf_printf(out, "cannot read %s: %s", path, strerror(e));
+ return (-1);
+ }
+ return (0);
+}
+
+int
+tool_write(int allow_write, const char *path, const char *content, size_t clen,
+ struct buf *out)
+{
+ if (!allow_write) {
+ buf_printf(out, "writing is disabled (allow_write no)");
+ return (-1);
+ }
+ if (write_atomic(path, content, clen, out) == -1)
+ return (-1);
+ buf_printf(out, "wrote %zu bytes to %s", clen, path);
+ return (0);
+}
+
+int
+tool_edit(int allow_write, const char *path, const char *old_string,
+ const char *new_string, struct buf *out)
+{
+ struct buf content, nc;
+ char *m;
+ size_t oldlen, newlen, off, tail;
+
+ if (!allow_write) {
+ buf_printf(out, "editing is disabled (allow_write no)");
+ return (-1);
+ }
+ if ((oldlen = strlen(old_string)) == 0) {
+ buf_printf(out, "edit: old_string is empty");
+ return (-1);
+ }
+
+ buf_init(&content);
+ if (slurp(path, &content) == -1) {
+ int e = errno;
+
+ buf_printf(out, "cannot read %s: %s", path, strerror(e));
+ buf_free(&content);
+ return (-1);
+ }
+
+ m = memmem(content.data, content.len, old_string, oldlen);
+ if (m == NULL) {
+ buf_printf(out, "old_string not found in %s", path);
+ buf_free(&content);
+ return (-1);
+ }
+ off = (size_t)(m - content.data);
+ if (memmem(m + 1, content.len - off - 1, old_string, oldlen) != NULL) {
+ buf_printf(out, "old_string is not unique in %s", path);
+ buf_free(&content);
+ return (-1);
+ }
+
+ newlen = strlen(new_string);
+ tail = content.len - off - oldlen;
+ buf_init(&nc);
+ buf_append(&nc, content.data, off);
+ buf_append(&nc, new_string, newlen);
+ buf_append(&nc, content.data + off + oldlen, tail);
+
+ if (write_atomic(path, nc.data, nc.len, out) == -1) {
+ buf_free(&content);
+ buf_free(&nc);
+ return (-1);
+ }
+ buf_printf(out, "edited %s", path);
+ buf_free(&content);
+ buf_free(&nc);
+ return (0);
+}
+
+static int
+write_all(int fd, const char *data, size_t len)
+{
+ size_t off = 0;
+ ssize_t n;
+
+ while (off < len) {
+ n = write(fd, data + off, len - off);
+ if (n == -1) {
+ if (errno == EINTR)
+ continue;
+ return (-1);
+ }
+ if (n == 0) { /* should not happen on a regular file */
+ errno = EPIPE;
+ return (-1);
+ }
+ off += (size_t)n;
+ }
+ return (0);
+}
+
+/* Read an entire file into content. 0 ok; -1 with errno (EISDIR/EFBIG/...). */
+static int
+slurp(const char *path, struct buf *content)
+{
+ struct stat st;
+ char b[65536];
+ ssize_t n;
+ int fd;
+
+ if ((fd = open(path, O_RDONLY)) == -1)
+ return (-1);
+ if (fstat(fd, &st) == 0 && S_ISDIR(st.st_mode)) {
+ close(fd);
+ errno = EISDIR;
+ return (-1);
+ }
+ for (;;) {
+ n = read(fd, b, sizeof(b));
+ if (n == -1) {
+ if (errno == EINTR)
+ continue;
+ close(fd);
+ return (-1);
+ }
+ if (n == 0)
+ break;
+ if (content->len + (size_t)n > TOOLS_MAXFILE) {
+ close(fd);
+ errno = EFBIG;
+ return (-1);
+ }
+ buf_append(content, b, (size_t)n);
+ }
+ close(fd);
+ return (0);
+}
+
+/*
+ * Write data to path durably and atomically: a sibling temp file, fsync,
+ * rename(2). On failure, an error message is left in out.
+ */
+static int
+write_atomic(const char *path, const char *data, size_t len, struct buf *out)
+{
+ struct stat st;
+ char tmp[PATH_MAX];
+ mode_t mode = 0644;
+ int fd;
+
+ if (stat(path, &st) == 0)
+ mode = st.st_mode & 0777; /* preserve an existing file's mode */
+ if (snprintf(tmp, sizeof(tmp), "%s.fugu.XXXXXX", path) >=
+ (int)sizeof(tmp)) {
+ buf_printf(out, "path too long: %s", path);
+ return (-1);
+ }
+ if ((fd = mkstemp(tmp)) == -1) {
+ buf_printf(out, "cannot create temp file for %s: %s", path,
+ strerror(errno));
+ return (-1);
+ }
+ if (write_all(fd, data, len) == -1) {
+ buf_printf(out, "write failed: %s", strerror(errno));
+ close(fd);
+ unlink(tmp);
+ return (-1);
+ }
+ (void)fchmod(fd, mode);
+ if (fsync(fd) == -1) {
+ buf_printf(out, "fsync failed: %s", strerror(errno));
+ close(fd);
+ unlink(tmp);
+ return (-1);
+ }
+ if (close(fd) == -1) {
+ buf_printf(out, "close failed: %s", strerror(errno));
+ unlink(tmp);
+ return (-1);
+ }
+ if (rename(tmp, path) == -1) {
+ buf_printf(out, "rename failed: %s", strerror(errno));
+ unlink(tmp);
+ return (-1);
+ }
+ return (0);
+}
blob - /dev/null
blob + d5de050e1f87ea064ab3a7cbd3a6b3199c0c4637 (mode 644)
--- /dev/null
+++ src/exec/tools_shell.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The subprocess tools: shell (/bin/ksh -c), grep, find, and ls, each running a
+ * base program and returning its combined stdout+stderr. A single helper,
+ * run_capture(), forks the child with stdin from /dev/null and stdout/stderr to
+ * a pipe, closes every other descriptor (notably the imsg channel on fd 3) so a
+ * tool cannot reach the parent, enforces a wall-clock timeout, and caps the
+ * captured output. Path and network confinement come from the worker's
+ * unveil(2)/pledge(2) sandbox, which the children inherit.
+ */
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "tools.h"
+
+static int run_capture(char *const [], struct buf *);
+
+/*
+ * Run argv to completion, appending its combined output to out. Returns 0 if
+ * the child ran (whatever its exit status), -1 if it could not be spawned or
+ * was killed for exceeding TOOLS_EXEC_TIMEOUT.
+ */
+static int
+run_capture(char *const argv[], struct buf *out)
+{
+ struct timespec deadline, now;
+ struct pollfd pfd;
+ char b[16384];
+ pid_t pid;
+ size_t total = 0;
+ int p[2], status, timedout = 0, truncated = 0;
+ ssize_t n;
+
+ if (pipe(p) == -1) {
+ buf_printf(out, "pipe: %s", strerror(errno));
+ return (-1);
+ }
+ if ((pid = fork()) == -1) {
+ buf_printf(out, "fork: %s", strerror(errno));
+ close(p[0]);
+ close(p[1]);
+ return (-1);
+ }
+ if (pid == 0) {
+ int nfd;
+
+ nfd = open("/dev/null", O_RDWR);
+ if (nfd != -1)
+ (void)dup2(nfd, STDIN_FILENO);
+ (void)dup2(p[1], STDOUT_FILENO);
+ (void)dup2(p[1], STDERR_FILENO);
+ closefrom(STDERR_FILENO + 1); /* drop imsg fd, pipe, nfd */
+ execv(argv[0], argv);
+ _exit(127);
+ }
+
+ close(p[1]);
+ pfd.fd = p[0];
+ pfd.events = POLLIN;
+ clock_gettime(CLOCK_MONOTONIC, &deadline);
+ deadline.tv_sec += TOOLS_EXEC_TIMEOUT;
+
+ for (;;) {
+ long ms;
+ int r;
+
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ ms = (deadline.tv_sec - now.tv_sec) * 1000 +
+ (deadline.tv_nsec - now.tv_nsec) / 1000000;
+ if (ms <= 0) {
+ kill(pid, SIGKILL);
+ timedout = 1;
+ break;
+ }
+ r = poll(&pfd, 1, ms > 1000 ? 1000 : (int)ms);
+ if (r == -1) {
+ if (errno == EINTR)
+ continue;
+ kill(pid, SIGKILL); /* poll failed: kill the child so the */
+ timedout = 1; /* waitpid below cannot block forever */
+ break;
+ }
+ if (r == 0)
+ continue; /* re-check the deadline */
+ n = read(p[0], b, sizeof(b));
+ if (n <= 0)
+ break; /* EOF: child closed the pipe */
+ if (total >= TOOLS_MAXOUTPUT) {
+ /* already at the cap and more bytes arrived: real truncation */
+ truncated = 1;
+ kill(pid, SIGKILL); /* enough; stop the producer */
+ break;
+ } else {
+ size_t room = TOOLS_MAXOUTPUT - total;
+ size_t take = (size_t)n > room ? room : (size_t)n;
+
+ buf_append(out, b, take);
+ total += take;
+ if (take < (size_t)n) {
+ /* this read had to be clipped: real truncation */
+ truncated = 1;
+ kill(pid, SIGKILL);
+ break;
+ }
+ }
+ }
+ close(p[0]);
+
+ while (waitpid(pid, &status, 0) == -1 && errno == EINTR)
+ ;
+
+ if (timedout) {
+ buf_printf(out, "\n[killed: exceeded %d second time limit]",
+ TOOLS_EXEC_TIMEOUT);
+ return (-1);
+ }
+ if (truncated)
+ buf_printf(out, "\n[output truncated at %d bytes]",
+ TOOLS_MAXOUTPUT);
+ else if (WIFSIGNALED(status))
+ buf_printf(out, "\n[terminated by signal %d]",
+ WTERMSIG(status));
+ else if (WIFEXITED(status) && WEXITSTATUS(status) != 0)
+ buf_printf(out, "\n[exit status %d]", WEXITSTATUS(status));
+ return (0);
+}
+
+int
+tool_shell(const char *command, struct buf *out)
+{
+ char *const argv[] = {
+ "/bin/ksh", "-c", (char *)(uintptr_t)command, NULL
+ };
+
+ return (run_capture(argv, out));
+}
+
+int
+tool_grep(const char *pattern, const char *path, struct buf *out)
+{
+ char *const argv[] = {
+ "/usr/bin/grep", "-RnI", "--", (char *)(uintptr_t)pattern,
+ (char *)(uintptr_t)(path != NULL ? path : "."), NULL
+ };
+
+ return (run_capture(argv, out));
+}
+
+int
+tool_find(const char *path, const char *name, struct buf *out)
+{
+ const char *root = path != NULL ? path : ".";
+
+ if (name != NULL) {
+ char *const argv[] = {
+ "/usr/bin/find", (char *)(uintptr_t)root, "-name",
+ (char *)(uintptr_t)name, NULL
+ };
+
+ return (run_capture(argv, out));
+ } else {
+ char *const argv[] = {
+ "/usr/bin/find", (char *)(uintptr_t)root, NULL
+ };
+
+ return (run_capture(argv, out));
+ }
+}
+
+int
+tool_ls(const char *path, struct buf *out)
+{
+ char *const argv[] = {
+ "/bin/ls", "-la", "--",
+ (char *)(uintptr_t)(path != NULL ? path : "."), NULL
+ };
+
+ return (run_capture(argv, out));
+}
blob - /dev/null
blob + 97e331623c4fc92b0e27604e046d9336b4e30c79 (mode 644)
--- /dev/null
+++ src/fetch/Makefile
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG= fugu-fetch
+SRCS+= fetch.c fetch_run.c kagi.c html2text.c http.c tlsconn.c
+BINDIR= ${PREFIX}/libexec/fugu
+LDADD+= -ltls
+DPADD+= ${LIBTLS}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 24fb2ef91fd7d0d261236d6742cdfc51eda6ed5e (mode 644)
--- /dev/null
+++ src/fetch/fetch.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-fetch: the web worker (${PREFIX}/libexec/fugu/fugu-fetch).
+ *
+ * Performs web_search (Kagi API) and web_fetch (arbitrary-URL HTTPS GET +
+ * HTML->text) via libtls, parsing untrusted HTML in isolation under pledge
+ * "stdio inet dns". Holds the Kagi token (from the parent over imsg) but NOT
+ * the Anthropic key, and can never exec. The imsg channel arrives on fd 3; not
+ * intended to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "fetch_run.h"
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+#define FUGU_FETCH_IMSG_FD 3
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgproto ip;
+ struct stat st;
+ extern char *__progname;
+ int ch, debug = 0, verbose = 0;
+
+ while ((ch = getopt(argc, argv, "dv")) != -1) {
+ switch (ch) {
+ case 'd':
+ debug = 1;
+ break;
+ case 'v':
+ verbose++;
+ break;
+ default:
+ break;
+ }
+ }
+
+ log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+ signal(SIGPIPE, SIG_IGN);
+ drop_privgroup(); /* never need the parent's setgid _fugu group */
+
+ if (fstat(FUGU_FETCH_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+ fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+ "directly\n", __progname);
+ return (1);
+ }
+ if (ip_init(&ip, FUGU_FETCH_IMSG_FD) == -1)
+ fatal("ip_init");
+
+ return (fetch_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 0b9b7fabd62b9627986ed889b0a6d9d71e25344f (mode 644)
--- /dev/null
+++ src/fetch/fetch_run.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The fetch worker: runs the brokered web tools in an address space that holds
+ * the Kagi token but never the Anthropic key, can never exec, and parses
+ * hostile web content in isolation. It receives M_TOOL_REQUEST {id,name,input}
+ * for web_search/web_fetch and replies M_TOOL_RESULT {id,content,is_error}.
+ *
+ * Startup pledge "stdio rpath inet dns" loads the CA bundle once; we then narrow
+ * to "stdio inet dns" and never touch a file again.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <netdb.h>
+#include <poll.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "fetch_run.h"
+#include "html2text.h"
+#include "http.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "kagi.h"
+#include "log.h"
+#include "tlsconn.h"
+#include "util.h"
+
+/*
+ * Inlined from src/common/url.c: parse an absolute http(s) URL into its
+ * connect parts. Defensive: host restricted to host characters (no Host:
+ * header smuggling), path rejects controls/space (no request-line smuggling),
+ * fragment dropped.
+ */
+struct url {
+ int https; /* 1 = https, 0 = http */
+ char host[256]; /* no brackets, even for IPv6 */
+ char port[8]; /* numeric, defaulted from the scheme */
+ char path[2048]; /* path + query, always begins with '/' */
+};
+
+static int url_parse(const char *in, struct url *out);
+
+/*
+ * Inlined from src/common/httpacl.c: host allow/block matcher for the model's
+ * http_request tool. Default-deny: empty/NULL allow denies everything; block
+ * wins. A pattern is HOSTPAT[/PATHPREFIX]; "." prefix on HOSTPAT makes it a
+ * domain-suffix pattern. Host compare is case-insensitive; path prefix is not.
+ */
+static int httpacl_allowed(const char *allow, const char *block,
+ const char *host, const char *path);
+static int acl_is_ws(int c);
+static int acl_host_match(const char *pat, size_t patlen, const char *host);
+static int acl_path_prefix_match(const char *path, const char *pref,
+ size_t preflen);
+static int acl_list_match(const char *list, const char *host,
+ const char *path);
+
+#define FETCH_DEFAULT_CAFILE "/etc/ssl/cert.pem"
+#define FETCH_MAXBODY (2 * 1024 * 1024) /* raw download cap */
+#define FETCH_DISPLAY_MAX (256 * 1024) /* non-HTML text cap */
+#define FETCH_USER_AGENT "fugu/0.1"
+#define FETCH_TIMEOUT 30 /* whole-request wall-clock seconds */
+
+struct fetchstate {
+ struct imsgproto *ip;
+ char *token; /* Kagi token (a secret) */
+ char *cafile;
+ char *kagi_host;
+ char *kagi_port;
+ struct tls_config *tlscfg;
+ int ready;
+ int allow_private; /* web_fetch may reach private IPs */
+ char *http_allow; /* http_request: allowed host patterns */
+ char *http_block; /* http_request: blocked host patterns */
+};
+
+/* Body sink for http_fetch: append decoded bytes up to a cap. */
+struct fetchsink {
+ struct buf *body;
+ size_t cap;
+ int truncated;
+};
+
+static void apply_config(struct fetchstate *, const void *, size_t);
+static void ensure_ready(struct fetchstate *);
+static void handle_tool(struct fetchstate *, const void *, size_t);
+static int do_web_search(struct fetchstate *, const char *, struct buf *);
+static int do_web_fetch(struct fetchstate *, const char *, struct buf *);
+static int do_http_request(struct fetchstate *, struct json_doc *, int,
+ struct buf *);
+static int http_fetch(struct fetchstate *, const char *host,
+ const char *port, const char *path, const char *method,
+ const void *reqbody, size_t reqbodylen,
+ const struct http_header *extra, size_t nextra, int guard,
+ struct http_resp *, struct buf *body, int *truncated,
+ char **errstr);
+static int ms_left(const struct timespec *);
+static int io_wait(int, short, const struct timespec *);
+static int addr_blocked(const struct sockaddr *);
+static int dial(const char *, const char *, int, const struct timespec *,
+ char **);
+static void fetch_body(const void *, size_t, void *);
+static void emit_result(struct fetchstate *, const char *, struct buf *,
+ int);
+static char *obj_getstr(struct json_doc *, int, const char *);
+/* obj_getstr_n lives in common/json.h now (promoted from this file). */
+static void state_clear(struct fetchstate *);
+
+int
+fetch_loop(struct imsgproto *ip)
+{
+ struct fetchstate fs;
+ struct fugu_msg m;
+ int r;
+
+ memset(&fs, 0, sizeof(fs));
+ fs.ip = ip;
+ fs.cafile = xstrdup(FETCH_DEFAULT_CAFILE);
+ fs.kagi_host = xstrdup(KAGI_HOST);
+ fs.kagi_port = xstrdup("443");
+
+ if (pledge("stdio rpath inet dns", NULL) == -1)
+ fatal("pledge");
+
+ if (ip_compose(ip, M_READY, EP_PARENT, EP_FETCH, NULL, 0) == -1 ||
+ ip_flush(ip) == -1)
+ fatalx("fetch: failed to send M_READY");
+
+ for (;;) {
+ r = ip_get(ip, &m);
+ if (r == -1) {
+ log_warnx("fetch: imsg protocol error");
+ break;
+ }
+ if (r == 0) {
+ if (ip_read(ip) <= 0)
+ break;
+ continue;
+ }
+ switch (m.type) {
+ case M_SECRET:
+ if (fs.token != NULL)
+ freezero(fs.token, strlen(fs.token));
+ fs.token = xmalloc(m.len + 1);
+ memcpy(fs.token, m.data, m.len);
+ fs.token[m.len] = '\0';
+ break;
+ case M_CONFIG:
+ apply_config(&fs, m.data, m.len);
+ ensure_ready(&fs);
+ break;
+ case M_TOOL_REQUEST:
+ handle_tool(&fs, m.data, m.len);
+ break;
+ case M_SHUTDOWN:
+ ip_msg_free(&m);
+ goto out;
+ default:
+ break;
+ }
+ ip_msg_free(&m);
+ }
+out:
+ state_clear(&fs);
+ return (0);
+}
+
+static void
+apply_config(struct fetchstate *fs, const void *data, size_t len)
+{
+ struct json_doc d;
+ char *s;
+ int t;
+
+ if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ log_warnx("fetch: malformed config");
+ return;
+ }
+ if ((s = obj_getstr(&d, 0, "cafile")) != NULL) {
+ free(fs->cafile);
+ fs->cafile = s;
+ }
+ if ((s = obj_getstr(&d, 0, "kagi_host")) != NULL) {
+ free(fs->kagi_host);
+ fs->kagi_host = s;
+ }
+ if ((s = obj_getstr(&d, 0, "kagi_port")) != NULL) {
+ free(fs->kagi_port);
+ fs->kagi_port = s;
+ }
+ if ((t = json_obj_get(&d, 0, "allow_private")) >= 0)
+ fs->allow_private = json_tok_streq(&d, t, "true");
+ if ((s = obj_getstr(&d, 0, "http_allow")) != NULL) {
+ free(fs->http_allow);
+ fs->http_allow = s;
+ }
+ if ((s = obj_getstr(&d, 0, "http_block")) != NULL) {
+ free(fs->http_block);
+ fs->http_block = s;
+ }
+ json_doc_free(&d);
+}
+
+static void
+ensure_ready(struct fetchstate *fs)
+{
+ if (fs->ready)
+ return;
+ fs->tlscfg = tlsconn_client_config(fs->cafile);
+ if (fs->tlscfg == NULL)
+ log_warnx("fetch: could not load CA bundle %s", fs->cafile);
+ if (pledge("stdio inet dns", NULL) == -1)
+ fatal("pledge");
+ fs->ready = 1;
+}
+
+static void
+handle_tool(struct fetchstate *fs, const void *data, size_t len)
+{
+ struct json_doc d;
+ struct buf out;
+ char *id = NULL, *name = NULL;
+ int input, is_error = 1;
+
+ buf_init(&out);
+ ensure_ready(fs);
+
+ if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ buf_puts_cstr(&out, "malformed tool request");
+ emit_result(fs, "", &out, 1);
+ json_doc_free(&d);
+ buf_free(&out);
+ return;
+ }
+
+ id = obj_getstr(&d, 0, "id");
+ name = obj_getstr(&d, 0, "name");
+ input = json_obj_get(&d, 0, "input");
+
+ if (name == NULL) {
+ buf_puts_cstr(&out, "missing tool name");
+ } else if (strcmp(name, "web_search") == 0) {
+ size_t qlen = 0;
+ char *q = obj_getstr_n(&d, input, "query", &qlen);
+
+ /*
+ * The query is JSON-serialised straight into the body that is
+ * POSTed to Kagi; a NUL would silently truncate it via strlen()
+ * inside the writer, so refuse rather than send the wrong query.
+ */
+ if (q == NULL)
+ buf_puts_cstr(&out, "web_search: missing 'query'");
+ else if (qlen != strlen(q))
+ buf_puts_cstr(&out, "web_search: 'query' must not contain "
+ "NUL bytes");
+ else
+ is_error = (do_web_search(fs, q, &out) != 0);
+ free(q);
+ } else if (strcmp(name, "web_fetch") == 0) {
+ size_t ulen = 0;
+ char *u = obj_getstr_n(&d, input, "url", &ulen);
+
+ /* The HTTP request-line cannot carry a NUL byte; reject. */
+ if (u == NULL)
+ buf_puts_cstr(&out, "web_fetch: missing 'url'");
+ else if (ulen != strlen(u))
+ buf_puts_cstr(&out, "web_fetch: 'url' must not contain "
+ "NUL bytes");
+ else
+ is_error = (do_web_fetch(fs, u, &out) != 0);
+ free(u);
+ } else if (strcmp(name, "http_request") == 0) {
+ is_error = (do_http_request(fs, &d, input, &out) != 0);
+ } else {
+ buf_printf(&out, "unknown tool: %s", name);
+ }
+
+ emit_result(fs, id != NULL ? id : "", &out, is_error);
+ free(id);
+ free(name);
+ json_doc_free(&d);
+ buf_free(&out);
+}
+
+static int
+do_web_search(struct fetchstate *fs, const char *query, struct buf *out)
+{
+ struct http_header hdr[2];
+ struct http_resp r;
+ struct buf reqbody, authval, body;
+ char *err = NULL;
+ int rc = -1, truncated = 0;
+
+ if (fs->token == NULL) {
+ buf_puts_cstr(out, "web_search is unavailable: no Kagi token "
+ "configured (set kagi_token in /etc/fugu.conf)");
+ return (-1);
+ }
+
+ buf_init(&reqbody);
+ buf_init(&authval);
+ buf_init(&body);
+ kagi_search_body(&reqbody, query); /* {"query":...,"limit":N} */
+ buf_printf(&authval, "Bearer %s", fs->token); /* v1 auth scheme */
+ buf_terminate(&authval); /* NUL-terminate, uncounted */
+ hdr[0].name = "authorization";
+ hdr[0].value = authval.data;
+ hdr[1].name = "content-type";
+ hdr[1].value = "application/json";
+
+ /* guard=0: the Kagi endpoint is the trusted, configured host. v1 = POST. */
+ if (http_fetch(fs, fs->kagi_host, fs->kagi_port, KAGI_SEARCH_PATH, "POST",
+ reqbody.data, reqbody.len, hdr, 2, 0, &r, &body, &truncated,
+ &err) == -1) {
+ buf_printf(out, "web_search: %s", err != NULL ? err :
+ "request failed");
+ free(err);
+ err = NULL;
+ } else if (r.status != 200) {
+ char *msg = body.len > 0 ?
+ kagi_error_msg(body.data, body.len) : NULL;
+
+ /* Surface Kagi's own reason (e.g. "Unauthorized") when present. */
+ if (msg != NULL)
+ buf_printf(out, "web_search: Kagi returned HTTP %d: %.*s",
+ r.status, 256, msg);
+ else
+ buf_printf(out, "web_search: Kagi returned HTTP %d",
+ r.status);
+ free(msg);
+ } else {
+ rc = kagi_format_results(body.data != NULL ? body.data : "",
+ body.len, out);
+ }
+
+ http_resp_clear(&r);
+ freezero(authval.data, authval.len); /* held the token */
+ buf_free(&reqbody);
+ buf_free(&body);
+ return (rc);
+}
+
+static int
+do_web_fetch(struct fetchstate *fs, const char *urlstr, struct buf *out)
+{
+ struct url u;
+ struct http_resp r;
+ struct buf body;
+ char *err = NULL, ct[160];
+ int rc = 0, truncated = 0, is_html, is_text;
+ size_t cl;
+
+ if (url_parse(urlstr, &u) == -1) {
+ buf_puts_cstr(out, "web_fetch: invalid or unsupported URL");
+ return (-1);
+ }
+ if (!u.https) {
+ buf_puts_cstr(out, "web_fetch: only https URLs are supported");
+ return (-1);
+ }
+
+ buf_init(&body);
+ /* guard the attacker-influenced destination unless explicitly allowed. */
+ if (http_fetch(fs, u.host, u.port, u.path, "GET", NULL, 0, NULL, 0,
+ fs->allow_private ? 0 : 1, &r, &body, &truncated, &err) == -1) {
+ buf_printf(out, "web_fetch: %s: %s", u.host, err != NULL ?
+ err : "request failed");
+ free(err);
+ err = NULL;
+ http_resp_clear(&r);
+ buf_free(&body);
+ return (-1);
+ }
+
+ /* fugu does not follow redirects (each hop would need re-guarding). */
+ if (r.status >= 300 && r.status < 400) {
+ buf_printf(out, "web_fetch: %s returned HTTP %d (a redirect). "
+ "fugu does not follow redirects; fetch the target URL "
+ "directly.", u.host, r.status);
+ http_resp_clear(&r);
+ buf_free(&body);
+ return (0);
+ }
+
+ ct[0] = '\0';
+ if (r.ctype.len > 0 && r.ctype.data != NULL) {
+ cl = r.ctype.len < sizeof(ct) - 1 ? r.ctype.len : sizeof(ct) - 1;
+ memcpy(ct, r.ctype.data, cl);
+ ct[cl] = '\0';
+ }
+ is_html = (strcasestr(ct, "html") != NULL);
+ is_text = (strncasecmp(ct, "text/", 5) == 0 || ct[0] == '\0');
+
+ if (r.status != 200)
+ buf_printf(out, "(HTTP %d from %s)\n\n", r.status, u.host);
+
+ if (is_html || ct[0] == '\0') {
+ html2text(body.data != NULL ? body.data : "", body.len, out);
+ } else if (is_text) {
+ size_t n = body.len < FETCH_DISPLAY_MAX ? body.len :
+ FETCH_DISPLAY_MAX;
+
+ if (body.data != NULL)
+ buf_append(out, body.data, n);
+ if (n < body.len)
+ truncated = 1;
+ } else {
+ buf_printf(out, "[%zu bytes of non-text content (%s)]",
+ body.len, ct[0] != '\0' ? ct : "unknown type");
+ }
+
+ if (truncated)
+ buf_printf(out, "\n\n[content truncated]");
+
+ http_resp_clear(&r);
+ buf_free(&body);
+ return (rc);
+}
+
+/* Canonical (uppercase) form of a supported HTTP method, or NULL if unknown. */
+static const char *
+method_canon(const char *m)
+{
+ static const char *const ok[] = { "GET", "POST", "PUT", "PATCH",
+ "DELETE", "HEAD", "OPTIONS" };
+ size_t i;
+
+ for (i = 0; i < sizeof(ok) / sizeof(ok[0]); i++)
+ if (strcasecmp(m, ok[i]) == 0)
+ return (ok[i]);
+ return (NULL);
+}
+
+/*
+ * http_request: a general HTTPS client the model drives. The operator's
+ * allow/block lists (httpacl) decide which hosts are reachable; the SSRF guard
+ * stays on (private/loopback/metadata refused) unless allow_private. The model
+ * supplies the method, headers and body; fugu injects none of its own
+ * credentials, so the model can only reach what the operator already trusts.
+ */
+static int
+do_http_request(struct fetchstate *fs, struct json_doc *d, int input,
+ struct buf *out)
+{
+#define HTTP_MAX_HDRS 24
+ struct url u;
+ struct http_resp r;
+ struct buf body;
+ struct http_header extra[HTTP_MAX_HDRS];
+ char *hname[HTTP_MAX_HDRS], *hval[HTTP_MAX_HDRS];
+ const char *method;
+ char *err = NULL, *urlstr, *rawmethod, *reqbody;
+ size_t nextra = 0, i, blen, urllen, methlen;
+ int hdrs, rc = -1, truncated = 0, bad_hdr = 0;
+
+ urlstr = obj_getstr_n(d, input, "url", &urllen);
+ if (urlstr == NULL) {
+ buf_puts_cstr(out, "http_request: missing 'url'");
+ return (-1);
+ }
+ /*
+ * The HTTP request line cannot carry a NUL byte; reject rather than
+ * silently truncating at the first NUL inside the URL.
+ */
+ if (urllen != strlen(urlstr)) {
+ buf_puts_cstr(out, "http_request: 'url' must not contain NUL bytes");
+ free(urlstr);
+ return (-1);
+ }
+ if (url_parse(urlstr, &u) == -1 || !u.https) {
+ buf_puts_cstr(out, "http_request: 'url' must be an absolute https URL");
+ free(urlstr);
+ return (-1);
+ }
+ free(urlstr);
+
+ /* The operator's allowlist (minus the blocklist) decides reachability. */
+ if (!httpacl_allowed(fs->http_allow, fs->http_block, u.host, u.path)) {
+ buf_printf(out, "http_request: host \"%s\" is not allowed by the "
+ "configured http_allow/http_block lists", u.host);
+ return (-1);
+ }
+
+ rawmethod = obj_getstr_n(d, input, "method", &methlen);
+ /* HTTP method is a protocol literal -- NULs are not allowed. */
+ if (rawmethod != NULL && methlen != strlen(rawmethod)) {
+ buf_puts_cstr(out, "http_request: 'method' must not contain NUL bytes");
+ free(rawmethod);
+ return (-1);
+ }
+ method = (rawmethod != NULL) ? method_canon(rawmethod) : "GET";
+ if (method == NULL) {
+ buf_printf(out, "http_request: unsupported method \"%s\"",
+ rawmethod);
+ free(rawmethod);
+ return (-1);
+ }
+
+ /*
+ * Body length comes from the decoded byte count -- NOT strlen() --
+ * because a model-supplied body can legitimately contain embedded NUL
+ * bytes (binary, protobuf, etc.). strlen() would silently truncate.
+ */
+ reqbody = obj_getstr_n(d, input, "body", &blen);
+
+ /* Turn the model's headers object into the extra-header array. */
+ hdrs = json_obj_get(d, input, "headers");
+ if (hdrs >= 0 && json_tok_type(d, hdrs) == JSON_OBJECT) {
+ int n = json_tok_size(d, hdrs), k = hdrs + 1, j;
+
+ for (j = 0; j < n && nextra < HTTP_MAX_HDRS; j++) {
+ int v = k + 1;
+ char *nm, *vl;
+ size_t nmlen = 0, vllen = 0;
+
+ if (json_tok_type(d, v) != JSON_STRING) {
+ k = json_skip(d, v); /* non-string value: skip */
+ continue;
+ }
+ nm = json_tok_strdup_n(d, k, &nmlen);
+ vl = json_tok_strdup_n(d, v, &vllen);
+ /*
+ * HTTP header field-names and field-values are written
+ * as C strings into the request, and the protocol does
+ * not permit a NUL byte in either. Reject the whole
+ * request rather than silently truncating.
+ */
+ if (nm != NULL && vl != NULL &&
+ (nmlen != strlen(nm) || vllen != strlen(vl))) {
+ free(nm);
+ free(vl);
+ bad_hdr = 1;
+ break;
+ }
+ /* host + content-length are owned by the transport. */
+ if (nm != NULL && vl != NULL &&
+ strcasecmp(nm, "host") != 0 &&
+ strcasecmp(nm, "content-length") != 0) {
+ hname[nextra] = nm;
+ hval[nextra] = vl;
+ extra[nextra].name = nm;
+ extra[nextra].value = vl;
+ nextra++;
+ } else {
+ free(nm);
+ free(vl);
+ }
+ k = json_skip(d, v);
+ }
+ }
+
+ if (bad_hdr) {
+ buf_puts_cstr(out, "http_request: header names and values must not "
+ "contain NUL bytes");
+ for (i = 0; i < nextra; i++) {
+ free(hname[i]);
+ free(hval[i]);
+ }
+ free(rawmethod);
+ free(reqbody);
+ return (-1);
+ }
+
+ buf_init(&body);
+ if (http_fetch(fs, u.host, u.port, u.path, method,
+ blen > 0 ? reqbody : NULL, blen, extra, nextra,
+ fs->allow_private ? 0 : 1, &r, &body, &truncated, &err) == -1) {
+ buf_printf(out, "http_request: %s: %s", u.host,
+ err != NULL ? err : "request failed");
+ free(err);
+ err = NULL;
+ } else {
+ size_t n = body.len < FETCH_DISPLAY_MAX ? body.len :
+ FETCH_DISPLAY_MAX;
+
+ /* Status line then the raw body (non-2xx is a result, not error). */
+ buf_printf(out, "HTTP %d\n\n", r.status);
+ if (body.data != NULL && n > 0)
+ buf_append(out, body.data, n);
+ if (truncated || n < body.len)
+ buf_puts_cstr(out, "\n\n[response truncated]");
+ rc = 0;
+ }
+
+ http_resp_clear(&r);
+ buf_free(&body);
+ for (i = 0; i < nextra; i++) {
+ free(hname[i]);
+ free(hval[i]);
+ }
+ free(rawmethod);
+ free(reqbody);
+ return (rc);
+#undef HTTP_MAX_HDRS
+}
+
+/* Milliseconds until the deadline, clamped to [0, FETCH_TIMEOUT*1000]. */
+static int
+ms_left(const struct timespec *dl)
+{
+ struct timespec now;
+ long ms;
+
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ ms = (dl->tv_sec - now.tv_sec) * 1000 +
+ (dl->tv_nsec - now.tv_nsec) / 1000000;
+ if (ms < 0)
+ return (0);
+ if (ms > 1000 * FETCH_TIMEOUT)
+ return (1000 * FETCH_TIMEOUT);
+ return ((int)ms);
+}
+
+/* poll fd for events until the deadline: 1 ready, 0 timeout, -1 error. */
+static int
+io_wait(int fd, short events, const struct timespec *dl)
+{
+ struct pollfd p;
+ int ms;
+
+ if ((ms = ms_left(dl)) <= 0)
+ return (0);
+ p.fd = fd;
+ p.events = events;
+ p.revents = 0;
+ return (poll(&p, 1, ms));
+}
+
+/*
+ * SSRF guard: is this resolved address loopback/private/link-local/metadata?
+ * web_fetch destinations are attacker-influenced, so by default we refuse to
+ * connect to anything that is not a public address.
+ */
+static int
+addr_blocked(const struct sockaddr *sa)
+{
+ if (sa->sa_family == AF_INET) {
+ uint32_t a = ntohl(((const struct sockaddr_in *)sa)->
+ sin_addr.s_addr);
+ uint8_t o1 = (a >> 24) & 0xff, o2 = (a >> 16) & 0xff;
+
+ if (o1 == 0 || o1 == 127) /* this-net, loopback */
+ return (1);
+ if (o1 == 10) /* RFC1918 */
+ return (1);
+ if (o1 == 172 && o2 >= 16 && o2 <= 31) /* RFC1918 */
+ return (1);
+ if (o1 == 192 && o2 == 168) /* RFC1918 */
+ return (1);
+ if (o1 == 169 && o2 == 254) /* link-local + metadata*/
+ return (1);
+ if (o1 == 100 && o2 >= 64 && o2 <= 127) /* CGNAT */
+ return (1);
+ if (o1 >= 224) /* multicast/reserved */
+ return (1);
+ return (0);
+ }
+ if (sa->sa_family == AF_INET6) {
+ const struct in6_addr *a6 =
+ &((const struct sockaddr_in6 *)sa)->sin6_addr;
+
+ if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_LINKLOCAL(a6) ||
+ IN6_IS_ADDR_MULTICAST(a6) || IN6_IS_ADDR_UNSPECIFIED(a6))
+ return (1);
+ if ((a6->s6_addr[0] & 0xfe) == 0xfc) /* fc00::/7 ULA */
+ return (1);
+ if (IN6_IS_ADDR_V4MAPPED(a6)) { /* ::ffff:v4 -> check v4*/
+ struct sockaddr_in s4;
+
+ memset(&s4, 0, sizeof(s4));
+ s4.sin_family = AF_INET;
+ memcpy(&s4.sin_addr, &a6->s6_addr[12], 4);
+ return (addr_blocked((struct sockaddr *)&s4));
+ }
+ return (0);
+ }
+ return (1); /* unknown family: refuse */
+}
+
+/*
+ * Resolve host:port and connect a non-blocking socket within the deadline.
+ * When guard is set, reject blocked addresses on the RESOLVED address (so
+ * decimal/hex host encodings and DNS rebinding cannot slip past). Returns
+ * the connected fd, or -1 with *errstr set to a heap-allocated, NUL-
+ * terminated description that the caller free()s -- same ownership shape
+ * as tlsconn_connect's *errstr so http_fetch can treat both kinds of
+ * failure uniformly.
+ */
+static int
+dial(const char *host, const char *port, int guard, const struct timespec *dl,
+ char **errstr)
+{
+ struct addrinfo hints, *res, *ai;
+ int fd = -1, blocked = 0;
+
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+ if (getaddrinfo(host, port, &hints, &res) != 0) {
+ *errstr = xstrdup("host resolution failed");
+ return (-1);
+ }
+ for (ai = res; ai != NULL; ai = ai->ai_next) {
+ int fl, err = 0;
+ socklen_t sl = sizeof(err);
+
+ if (guard && addr_blocked(ai->ai_addr)) {
+ blocked = 1;
+ continue;
+ }
+ if ((fd = socket(ai->ai_family, ai->ai_socktype,
+ ai->ai_protocol)) == -1)
+ continue;
+ if ((fl = fcntl(fd, F_GETFL)) != -1)
+ (void)fcntl(fd, F_SETFL, fl | O_NONBLOCK);
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
+ break; /* connected immediately */
+ if (errno == EINPROGRESS && io_wait(fd, POLLOUT, dl) == 1 &&
+ getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &sl) == 0 &&
+ err == 0)
+ break; /* connected */
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+ if (fd == -1)
+ *errstr = xstrdup(blocked ?
+ "destination address is not allowed" :
+ "could not connect (timeout or refused)");
+ return (fd);
+}
+
+/*
+ * One HTTPS request (GET, or POST when reqbody is non-NULL) with a whole-request
+ * wall-clock deadline (connect + handshake + write + read) and an SSRF guard. r
+ * is always initialised on return (clear it). 0 on a received response
+ * (r.status/r.ctype/body filled), -1 on a transport failure with *errstr set.
+ */
+static int
+http_fetch(struct fetchstate *fs, const char *host, const char *port,
+ const char *path, const char *method, const void *reqbody, size_t reqbodylen,
+ const struct http_header *extra, size_t nextra, int guard,
+ struct http_resp *r, struct buf *body, int *truncated, char **errstr)
+{
+ struct tlsconn c;
+ struct http_header h[32]; /* user-agent + accept + extras */
+ struct fetchsink sink;
+ struct buf req;
+ struct timespec dl;
+ char *tlserr = NULL;
+ char hosthdr[280], rbuf[8192];
+ ssize_t n;
+ size_t nh = 0, i;
+ int fd = -1, done = 0;
+
+ memset(&c, 0, sizeof(c));
+ c.fd = -1;
+ sink.body = body;
+ sink.cap = FETCH_MAXBODY;
+ sink.truncated = 0;
+ http_resp_init(r, fetch_body, &sink);
+
+ if (fs->tlscfg == NULL) {
+ *errstr = xstrdup("TLS not configured");
+ return (-1);
+ }
+
+ clock_gettime(CLOCK_MONOTONIC, &dl);
+ dl.tv_sec += FETCH_TIMEOUT;
+
+ if ((fd = dial(host, port, guard, &dl, errstr)) == -1)
+ return (-1);
+
+ /* Host header: bracket IPv6 literals, add a non-default port. */
+ if (strchr(host, ':') != NULL) {
+ if (strcmp(port, "443") == 0)
+ snprintf(hosthdr, sizeof(hosthdr), "[%s]", host);
+ else
+ snprintf(hosthdr, sizeof(hosthdr), "[%s]:%s", host,
+ port);
+ } else if (strcmp(port, "443") == 0)
+ (void)strlcpy(hosthdr, host, sizeof(hosthdr));
+ else
+ snprintf(hosthdr, sizeof(hosthdr), "%s:%s", host, port);
+
+ h[nh].name = "user-agent";
+ h[nh].value = FETCH_USER_AGENT;
+ nh++;
+ h[nh].name = "accept";
+ h[nh].value = "*/*";
+ nh++;
+ for (i = 0; i < nextra && nh < sizeof(h) / sizeof(h[0]); i++)
+ h[nh++] = extra[i];
+
+ buf_init(&req);
+ http_build_request(&req, method, hosthdr, path, h, nh, reqbody, reqbodylen);
+
+ /*
+ * Attach TLS to the dialed fd via the shared tlsconn module. On
+ * success c takes ownership of fd (tlsconn_close closes it); on
+ * failure we still own fd and the close()-on-out path runs. tlserr is
+ * a heap-owned libtls reason -- splice it into *errstr so the user
+ * sees the real cause (expired cert, name mismatch, etc.) instead of
+ * the historical canned literal "TLS handshake failed".
+ */
+ if (tlsconn_attach(&c, fs->tlscfg, fd, host, &tlserr) == -1) {
+ struct buf msg;
+
+ buf_init(&msg);
+ buf_printf(&msg, "TLS handshake failed: %s",
+ tlserr != NULL ? tlserr : "(no reason)");
+ buf_terminate(&msg);
+ *errstr = xstrdup(buf_cstr(&msg));
+ buf_free(&msg);
+ free(tlserr);
+ /* fd not transferred -- the out: label closes it. */
+ goto out;
+ }
+ fd = -1; /* c.fd owns the socket now; out: must not close */
+
+ if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+ *errstr = xstrdup("request write failed");
+ goto out;
+ }
+
+ for (;;) { /* read the response */
+ n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+ if (n == 0) {
+ http_resp_eof(r);
+ done = 1;
+ break;
+ }
+ if (n < 0) {
+ *errstr = xstrdup("connection read failed");
+ goto out;
+ }
+ if (http_resp_push(r, rbuf, (size_t)n) == -1) {
+ *errstr = xstrdup("malformed HTTP response");
+ goto out;
+ }
+ if (sink.truncated || http_resp_done(r)) { /* cap reached */
+ done = 1;
+ break;
+ }
+ }
+out:
+ *truncated = sink.truncated;
+ tlsconn_close(&c); /* idempotent; no-op if attach failed */
+ if (fd != -1) /* attach failed -- caller's fd never moved */
+ close(fd);
+ buf_free(&req);
+ return (done ? 0 : -1);
+}
+
+static void
+fetch_body(const void *data, size_t len, void *arg)
+{
+ struct fetchsink *s = arg;
+ size_t room, take;
+
+ if (s->body->len >= s->cap) {
+ s->truncated = 1;
+ return;
+ }
+ room = s->cap - s->body->len;
+ take = len > room ? room : len;
+ buf_append(s->body, data, take);
+ if (take < len)
+ s->truncated = 1;
+}
+
+static void
+emit_result(struct fetchstate *fs, const char *id, struct buf *content,
+ int is_error)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_key(&w, "content");
+ json_string_n(&w, content->len > 0 ? content->data : "", content->len);
+ json_kv_bool(&w, "is_error", is_error);
+ json_object_end(&w);
+
+ if (ip_compose(fs->ip, M_TOOL_RESULT, EP_NET, EP_FETCH, b.data,
+ b.len) == -1 || ip_flush(fs->ip) == -1)
+ fatalx("fetch: failed to send result");
+ buf_free(&b);
+}
+
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+ int t;
+
+ if (obj < 0)
+ return (NULL);
+ if ((t = json_obj_get(d, obj, key)) < 0 ||
+ json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
+
+static void
+state_clear(struct fetchstate *fs)
+{
+ if (fs->token != NULL)
+ freezero(fs->token, strlen(fs->token));
+ free(fs->cafile);
+ free(fs->kagi_host);
+ free(fs->kagi_port);
+ free(fs->http_allow);
+ free(fs->http_block);
+ if (fs->tlscfg != NULL)
+ tls_config_free(fs->tlscfg);
+ memset(fs, 0, sizeof(*fs));
+}
+
+/* ------------------------------------------------------------------------ */
+/* Inlined url module (was src/common/url.c). */
+/* ------------------------------------------------------------------------ */
+
+static int
+url_parse(const char *in, struct url *u)
+{
+ const char *p, *aend, *a, *at, *colon, *rb, *hp;
+ const char *errstr;
+ char *h;
+ size_t alen, hlen, plen, i;
+
+ memset(u, 0, sizeof(*u));
+ if (strncasecmp(in, "https://", 8) == 0) {
+ u->https = 1;
+ p = in + 8;
+ } else if (strncasecmp(in, "http://", 7) == 0) {
+ u->https = 0;
+ p = in + 7;
+ } else
+ return (-1);
+
+ alen = strcspn(p, "/?#"); /* authority ends at path/query/frag */
+ aend = p + alen;
+
+ /* Path + query (root-synthesised when absent); the fragment is dropped. */
+ if (*aend == '/') {
+ if (strlcpy(u->path, aend, sizeof(u->path)) >= sizeof(u->path))
+ return (-1);
+ } else {
+ u->path[0] = '/';
+ u->path[1] = '\0';
+ if (*aend == '?' &&
+ strlcat(u->path, aend, sizeof(u->path)) >= sizeof(u->path))
+ return (-1);
+ }
+ if ((h = strchr(u->path, '#')) != NULL)
+ *h = '\0';
+
+ /* Authority: optional userinfo@, host (maybe [IPv6]), optional :port. */
+ a = p;
+ if ((at = memchr(p, '@', alen)) != NULL)
+ a = at + 1;
+
+ if (*a == '[') {
+ if ((rb = memchr(a, ']', (size_t)(aend - a))) == NULL)
+ return (-1);
+ hp = a + 1;
+ hlen = (size_t)(rb - (a + 1));
+ a = rb + 1;
+ } else {
+ colon = memchr(a, ':', (size_t)(aend - a));
+ hp = a;
+ hlen = (size_t)((colon != NULL ? colon : aend) - a);
+ a = (colon != NULL ? colon : aend);
+ }
+ if (hlen == 0 || hlen >= sizeof(u->host))
+ return (-1);
+ memcpy(u->host, hp, hlen);
+ u->host[hlen] = '\0';
+
+ if (a < aend && *a == ':') {
+ a++;
+ plen = (size_t)(aend - a);
+ if (plen == 0 || plen >= sizeof(u->port))
+ return (-1);
+ memcpy(u->port, a, plen);
+ u->port[plen] = '\0';
+ (void)strtonum(u->port, 1, 65535, &errstr);
+ if (errstr != NULL)
+ return (-1);
+ } else if (a < aend)
+ return (-1); /* trailing junk after a literal host */
+ else
+ (void)strlcpy(u->port, u->https ? "443" : "80",
+ sizeof(u->port));
+
+ /* Host may only contain host characters (no header smuggling). */
+ for (i = 0; u->host[i] != '\0'; i++) {
+ unsigned char hc = (unsigned char)u->host[i];
+
+ if (!(isalnum(hc) || hc == '.' || hc == '-' || hc == ':'))
+ return (-1);
+ }
+ /* Path may not contain a space or any control byte (no request smuggling). */
+ for (i = 0; u->path[i] != '\0'; i++) {
+ unsigned char pc = (unsigned char)u->path[i];
+
+ if (pc <= 0x20 || pc == 0x7f)
+ return (-1);
+ }
+ return (0);
+}
+
+/* ------------------------------------------------------------------------ */
+/* Inlined httpacl module (was src/common/httpacl.c). */
+/* ------------------------------------------------------------------------ */
+
+static int
+acl_is_ws(int c)
+{
+ return (c == ' ' || c == '\t' || c == '\n' || c == '\r');
+}
+
+/*
+ * Case-insensitive match of host against the host part of one pattern token.
+ * A leading '.' makes it a domain suffix: ".example.com" matches both the bare
+ * "example.com" and any "*.example.com".
+ */
+static int
+acl_host_match(const char *pat, size_t patlen, const char *host)
+{
+ size_t hlen = strlen(host);
+
+ if (patlen == 0)
+ return (0);
+ if (pat[0] == '.') {
+ const char *bare = pat + 1;
+ size_t barelen = patlen - 1;
+
+ if (barelen == 0)
+ return (0);
+ /* exact match against the bare domain "example.com" */
+ if (hlen == barelen && strncasecmp(host, bare, barelen) == 0)
+ return (1);
+ /* suffix match: host ends with ".example.com" */
+ if (hlen > patlen &&
+ strncasecmp(host + (hlen - patlen), pat, patlen) == 0)
+ return (1);
+ return (0);
+ }
+ return (hlen == patlen && strncasecmp(host, pat, patlen) == 0);
+}
+
+/*
+ * Path-prefix match on a path-segment boundary: pref matches path iff path
+ * starts with pref AND the next byte is end-of-string or a '/'. Without the
+ * boundary check, pattern "v1" would also match "v1admin/secrets", letting a
+ * sibling path slip past the ACL.
+ */
+static int
+acl_path_prefix_match(const char *path, const char *pref, size_t preflen)
+{
+ if (strncmp(path, pref, preflen) != 0)
+ return (0);
+ return (path[preflen] == '\0' || path[preflen] == '/' ||
+ path[preflen] == '?' || path[preflen] == '#');
+}
+
+/* Does host/path match any whitespace-separated token in list? */
+static int
+acl_list_match(const char *list, const char *host, const char *path)
+{
+ const char *p = list;
+
+ if (list == NULL)
+ return (0);
+ while (*p != '\0') {
+ const char *tok, *slash;
+ size_t toklen, hostlen;
+
+ while (acl_is_ws((unsigned char)*p))
+ p++;
+ if (*p == '\0')
+ break;
+ tok = p;
+ while (*p != '\0' && !acl_is_ws((unsigned char)*p))
+ p++;
+ toklen = (size_t)(p - tok);
+
+ /* split host[/pathprefix] on the first '/' */
+ slash = memchr(tok, '/', toklen);
+ hostlen = (slash != NULL) ? (size_t)(slash - tok) : toklen;
+
+ if (!acl_host_match(tok, hostlen, host))
+ continue;
+ if (slash == NULL)
+ return (1); /* host-only pattern: any path */
+
+ /*
+ * Path-prefix match. Request paths begin with '/'; compare the
+ * pattern's prefix against the path with that leading '/' dropped
+ * so "host/v1" matches the path "/v1/...". Anchored to a segment
+ * boundary so "host/v1" does not match "/v1admin/secrets".
+ */
+ {
+ const char *pref = slash + 1;
+ size_t preflen = toklen - hostlen - 1;
+ const char *rp;
+
+ if (path == NULL)
+ continue;
+ rp = (path[0] == '/') ? path + 1 : path;
+ if (acl_path_prefix_match(rp, pref, preflen))
+ return (1);
+ }
+ }
+ return (0);
+}
+
+static int
+httpacl_allowed(const char *allow, const char *block, const char *host,
+ const char *path)
+{
+ if (host == NULL || *host == '\0')
+ return (0);
+ if (!acl_list_match(allow, host, path))
+ return (0); /* default-deny */
+ if (acl_list_match(block, host, path))
+ return (0); /* block wins */
+ return (1);
+}
+
blob - /dev/null
blob + 9f7f981556fd4e75401496035c9765f2cd3e869f (mode 644)
--- /dev/null
+++ src/fetch/fetch_run.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_FETCH_RUN_H
+#define FUGU_FETCH_RUN_H
+
+#include "imsgproto.h"
+
+/*
+ * Service web_search/web_fetch tool requests on the imsg channel to the parent.
+ * Holds only the Kagi token; pledges "stdio inet dns" once the CA bundle is
+ * loaded. Returns 0 on a clean exit.
+ */
+int fetch_loop(struct imsgproto *);
+
+#endif /* FUGU_FETCH_RUN_H */
blob - /dev/null
blob + 25bb5bd212a7627c35d0541f0386d45c2698d799 (mode 644)
--- /dev/null
+++ src/fetch/kagi.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdlib.h>
+
+#include "buf.h"
+#include "json.h"
+#include "kagi.h"
+
+#define KAGI_TITLE_MAX 256 /* cap each field so one hostile */
+#define KAGI_URL_MAX 1024 /* result cannot flood the model */
+#define KAGI_SNIPPET_MAX 2048
+
+static char *field(struct json_doc *, int, const char *);
+
+void
+kagi_search_body(struct buf *body, const char *query)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, body);
+ json_object_start(&w);
+ json_kv_string(&w, "query", query); /* JSON-escaped by the writer */
+ json_kv_int(&w, "limit", KAGI_MAX_RESULTS);
+ json_object_end(&w);
+}
+
+int
+kagi_format_results(const char *json, size_t len, struct buf *out)
+{
+ struct json_doc d;
+ int data, search, err, n, e, i, count = 0;
+
+ if (json_parse(&d, json, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ buf_puts_cstr(out, "web_search: malformed response from Kagi");
+ json_doc_free(&d);
+ return (-1);
+ }
+
+ /* v1 error envelope: {"errors":[{"code":..,"message":".."}], ...}. */
+ if ((err = json_obj_get(&d, 0, "errors")) >= 0) {
+ char *msg = NULL;
+
+ if (json_tok_type(&d, err) == JSON_ARRAY &&
+ json_tok_size(&d, err) > 0)
+ msg = field(&d, err + 1, "message");
+ buf_printf(out, "web_search: Kagi API error%s%s",
+ msg != NULL ? ": " : "", msg != NULL ? msg : "");
+ free(msg);
+ json_doc_free(&d);
+ return (-1);
+ }
+
+ /* v1 success: {"data":{"search":[{title,url,snippet}],"related_search":..}} */
+ data = json_obj_get(&d, 0, "data");
+ if (data < 0 || json_tok_type(&d, data) != JSON_OBJECT) {
+ buf_puts_cstr(out, "web_search: unexpected response shape from Kagi");
+ json_doc_free(&d);
+ return (-1);
+ }
+ search = json_obj_get(&d, data, "search");
+ if (search < 0 || json_tok_type(&d, search) != JSON_ARRAY) {
+ buf_puts_cstr(out, "No results."); /* answer-only or empty result set */
+ json_doc_free(&d);
+ return (0);
+ }
+
+ n = json_tok_size(&d, search);
+ e = search + 1;
+ for (i = 0; i < n; i++) {
+ if (json_tok_type(&d, e) == JSON_OBJECT &&
+ count < KAGI_MAX_RESULTS) {
+ char *title = field(&d, e, "title");
+ char *u = field(&d, e, "url");
+ char *snip = field(&d, e, "snippet");
+
+ count++;
+ buf_printf(out, "%d. %.*s\n", count, KAGI_TITLE_MAX,
+ title != NULL ? title : "(untitled)");
+ if (u != NULL)
+ buf_printf(out, " %.*s\n", KAGI_URL_MAX, u);
+ if (snip != NULL)
+ buf_printf(out, " %.*s\n", KAGI_SNIPPET_MAX,
+ snip);
+ buf_putc(out, '\n');
+ free(title);
+ free(u);
+ free(snip);
+ }
+ e = json_skip(&d, e);
+ }
+
+ if (count == 0)
+ buf_puts_cstr(out, "No results.");
+ json_doc_free(&d);
+ return (0);
+}
+
+char *
+kagi_error_msg(const char *json, size_t len)
+{
+ struct json_doc d;
+ char *msg = NULL;
+ int err;
+
+ if (json_parse(&d, json, len) == 0 && d.ntok >= 1 &&
+ json_tok_type(&d, 0) == JSON_OBJECT &&
+ (err = json_obj_get(&d, 0, "errors")) >= 0 &&
+ json_tok_type(&d, err) == JSON_ARRAY &&
+ json_tok_size(&d, err) > 0)
+ msg = field(&d, err + 1, "message");
+ json_doc_free(&d);
+ return (msg);
+}
+
+static char *
+field(struct json_doc *d, int obj, const char *key)
+{
+ int t;
+
+ if (obj < 0)
+ return (NULL);
+ if ((t = json_obj_get(d, obj, key)) < 0 ||
+ json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
blob - /dev/null
blob + f142d86ba582980ad31d6403a68493dd988400bb (mode 644)
--- /dev/null
+++ src/fetch/kagi.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * kagi: build a Kagi Search API request and render its JSON response into the
+ * text the model sees. The HTTP transport and the Authorization header live in
+ * the fetch worker; this file is the pure request-path/response-shape logic.
+ */
+
+#ifndef FUGU_KAGI_H
+#define FUGU_KAGI_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define KAGI_HOST "kagi.com"
+#define KAGI_SEARCH_PATH "/api/v1/search" /* POST, JSON body */
+#define KAGI_MAX_RESULTS 10
+
+/* Build the v1 POST request body: {"query":"<query>","limit":N}. */
+void kagi_search_body(struct buf *body, const char *query);
+
+/*
+ * Render a Kagi v1 search response (the JSON body) into out as a numbered list
+ * of title/url/snippet drawn from data.search[]. 0 on success (out holds the
+ * listing or "No results."), -1 if the body is not a well-formed success
+ * envelope (out then holds a short error note).
+ */
+int kagi_format_results(const char *json, size_t len, struct buf *out);
+
+/*
+ * Extract the message from a Kagi v1 error envelope ({"errors":[{"message":
+ * ".."}]}), used to explain a non-200 reply. Returns a malloc'd string the
+ * caller frees, or NULL if the body is not such an envelope.
+ */
+char *kagi_error_msg(const char *json, size_t len);
+
+#endif /* FUGU_KAGI_H */
blob - /dev/null
blob + a9244b9eb7581aa6b0c04bf8efe44fa891551287 (mode 644)
--- /dev/null
+++ src/net/Makefile
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG= fugu-net
+SRCS+= net.c net_run.c net_api.c http.c sse.c anthropic.c openai.c tlsconn.c
+BINDIR= ${PREFIX}/libexec/fugu
+LDADD+= -ltls
+DPADD+= ${LIBTLS}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 7d6415b7f9ab4469c5db41c2df01d52e451cf5f2 (mode 644)
--- /dev/null
+++ src/net/net.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-net: the network worker (${PREFIX}/libexec/fugu/fugu-net).
+ *
+ * Holds the Anthropic API key (received from the parent over imsg, never read
+ * from a file) and speaks HTTPS to api.anthropic.com via libtls, driving the
+ * streaming agent loop under pledge "stdio inet dns". No exec, no curses.
+ *
+ * The parent passes the imsg channel on fd FUGU_NET_IMSG_FD; the worker is not
+ * meant to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "imsgproto.h"
+#include "log.h"
+#include "net_run.h"
+#include "util.h"
+
+#define FUGU_NET_IMSG_FD 3
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgproto ip;
+ struct stat st;
+ extern char *__progname;
+ int ch, debug = 0, verbose = 0;
+
+ while ((ch = getopt(argc, argv, "dv")) != -1) {
+ switch (ch) {
+ case 'd':
+ debug = 1;
+ break;
+ case 'v':
+ verbose++;
+ break;
+ default:
+ break;
+ }
+ }
+
+ log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+ signal(SIGPIPE, SIG_IGN); /* a peer hangup must not kill us */
+ drop_privgroup(); /* never need the parent's setgid _fugu group */
+
+ if (fstat(FUGU_NET_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+ fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+ "directly\n", __progname);
+ return (1);
+ }
+ if (ip_init(&ip, FUGU_NET_IMSG_FD) == -1)
+ fatal("ip_init");
+
+ return (net_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + ed088fe4c3c9b3c8e1102e282467048ebd5d5b1c (mode 644)
--- /dev/null
+++ src/net/net_api.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The transport seam: one streamed Messages call (net_api_call) and the models
+ * endpoint (net_fetch_models) per provider. Both terminate at a struct buf the
+ * caller owns -- no side-channel through netstate -- so the agent loop stays
+ * unaware of HTTP, TLS, SSE, or the provider's wire format. The provider's
+ * ops table fans out request building and stream decoding; the semantic
+ * callbacks below relay text/tool_use/usage/stop to the UI and back into the
+ * turncollect the caller passed in.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "net_api.h"
+#include "net_run.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "sse.h"
+#include "tlsconn.h"
+#include "util.h"
+
+#define NET_API_VERSION "2023-06-01"
+#define MODELS_MAXBODY (1U << 20) /* untrusted models body cap */
+
+/* Body sink: a 200 response feeds the SSE parser, otherwise capture the error. */
+struct bodysink {
+ struct http_resp *r;
+ struct sse_parser *sse;
+ struct buf err;
+};
+
+/* Route each SSE event to the active provider's stream decoder. */
+struct streamctx {
+ const struct net_provider *prov;
+ union llm_stream_storage *ctx; /* the provider's stream object */
+};
+
+/*
+ * Bounded sink for the untrusted (non-streamed) models response body: append
+ * until the cap, then flag truncation -- a hostile or runaway server must not
+ * be able to grow the worker's heap without limit. Mirrors the fetch worker's
+ * FETCH_MAXBODY guard.
+ */
+struct models_sink {
+ struct buf *buf;
+ size_t cap;
+ int truncated;
+};
+
+/*
+ * Per-call context passed to every semantic callback. Bundles the netstate
+ * (for emitting deltas, errors, and reading the summarizing flag) with the
+ * turncollect to populate -- one allocation per call, on the agent loop's
+ * stack, threaded as the callback arg via stream_init.
+ */
+struct callctx {
+ struct netstate *ns;
+ struct turncollect *tc;
+};
+
+static void net_body(const void *, size_t, void *);
+static void sse_to_provider(const char *, const char *, size_t, void *);
+
+static void on_text(const char *, size_t, void *);
+static void on_tool_call(const char *, const char *, const char *, size_t,
+ void *);
+static void on_usage(long long, long long, void *);
+static void on_stop(const char *, void *);
+static void on_err(const char *, const char *, void *);
+static void on_done(void *);
+
+static void models_body(const void *, size_t, void *);
+static void net_models_path(struct net_provider *, struct buf *);
+
+static const struct llm_cbs NET_CBS = {
+ on_text, on_tool_call, on_usage, on_stop, on_err, on_done
+};
+
+void
+turncollect_init(struct turncollect *tc)
+{
+ memset(tc, 0, sizeof(*tc));
+ buf_init(&tc->text);
+}
+
+void
+turncollect_free(struct turncollect *tc)
+{
+ size_t i;
+
+ buf_free(&tc->text);
+ for (i = 0; i < tc->ntools; i++) {
+ free(tc->tools[i].id);
+ free(tc->tools[i].name);
+ buf_free(&tc->tools[i].input);
+ }
+ free(tc->tools);
+ free(tc->stop_reason);
+ memset(tc, 0, sizeof(*tc));
+}
+
+/* One streamed Messages call. Results land in *out; out->error on parse fault. */
+int
+net_api_call(struct netstate *ns, struct turncollect *out, struct buf *conv,
+ const char *system, const char *tools)
+{
+ struct http_header h[4];
+ struct tlsconn c;
+ struct http_resp r;
+ struct sse_parser p;
+ union llm_stream_storage stream;
+ struct streamctx sc;
+ struct bodysink bs;
+ struct callctx cc;
+ struct buf body, req, authval;
+ struct net_provider *prov = net_active(ns);
+ const char *path = NULL;
+ char *err = NULL;
+ char rbuf[4096];
+ int nh = 0, rc = -1;
+ ssize_t n;
+
+ if (prov == NULL) { /* net_run_turn guarantees this; be safe */
+ net_emit_error(ns, "auth", "no provider");
+ return (-1);
+ }
+
+ buf_terminate(conv); /* NUL-terminate for the raw splice */
+
+ buf_init(&body);
+ buf_init(&req);
+ buf_init(&authval);
+
+ /*
+ * Both halves -- request body and, below, response decoding -- are the
+ * only provider-specific parts; everything around them is shared.
+ */
+ prov->ops->prep_request(&body, h, 4, &nh, &authval, &path, prov->key,
+ prov->model, ns->max_tokens, system, conv->data, tools);
+ if (prov->path != NULL) /* explicit override wins over the default */
+ path = prov->path;
+ http_build_request(&req, "POST", prov->host, path, h, nh,
+ body.data, body.len);
+ buf_freezero(&authval); /* held the bearer token; scrub it */
+
+ if (tlsconn_connect(&c, ns->tlscfg, prov->host, prov->port, &err) == -1) {
+ net_emit_error(ns, "connect", err);
+ free(err); /* tlsconn_connect hands ownership over */
+ buf_free(&body);
+ buf_freezero(&req); /* embeds the auth header */
+ return (-1);
+ }
+ if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+ net_emit_error(ns, "write", "request failed");
+ goto done;
+ }
+
+ cc.ns = ns;
+ cc.tc = out;
+ prov->ops->stream_init(&stream, &NET_CBS, &cc);
+ sc.prov = prov;
+ sc.ctx = &stream;
+ sse_init(&p, sse_to_provider, &sc);
+ bs.r = &r;
+ bs.sse = &p;
+ buf_init(&bs.err);
+ http_resp_init(&r, net_body, &bs);
+
+ for (;;) {
+ n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+ if (n == 0) {
+ http_resp_eof(&r);
+ break;
+ }
+ if (n == -1) {
+ net_emit_error(ns, "read", "connection failed");
+ out->error = 1;
+ break;
+ }
+ if (http_resp_push(&r, rbuf, (size_t)n) == -1) {
+ net_emit_error(ns, "http", "malformed response");
+ out->error = 1;
+ break;
+ }
+ if (http_resp_done(&r))
+ break;
+ }
+ if (r.status != 0 && r.status != 200 && !out->error) {
+ struct buf e;
+ int elen = bs.err.len > 600 ? 600 : (int)bs.err.len;
+
+ buf_init(&e);
+ buf_printf(&e, "HTTP %d: %.*s", r.status, elen, bs.err.data);
+ net_emit(ns, M_ERROR, EP_UI, e.data, e.len);
+ buf_free(&e);
+ out->error = 1;
+ }
+
+ buf_free(&bs.err);
+ prov->ops->stream_clear(&stream);
+ sse_clear(&p);
+ http_resp_clear(&r);
+ rc = 0;
+done:
+ tlsconn_close(&c);
+ buf_free(&body);
+ buf_freezero(&req); /* embeds the auth header */
+ return (rc);
+}
+
+/*
+ * GET the provider's models endpoint, collect the body, and parse the ids into
+ * out. 0 on success; -1 (with an M_ERROR already emitted) on failure.
+ */
+int
+net_fetch_models(struct netstate *ns, struct net_provider *p, struct buf *out)
+{
+ struct http_header h[3];
+ struct tlsconn c;
+ struct http_resp r;
+ struct buf path, authval, req, body;
+ struct models_sink sink;
+ char *err = NULL;
+ char rbuf[4096];
+ ssize_t n;
+ int nh, rc = -1;
+
+ buf_init(&path);
+ buf_init(&authval);
+ buf_init(&req);
+ buf_init(&body);
+ net_models_path(p, &path);
+
+ if (p->type == LLM_OPENAI) {
+ buf_printf(&authval, "Bearer %s", p->key);
+ buf_terminate(&authval);
+ h[0].name = "authorization"; h[0].value = authval.data;
+ h[1].name = "accept"; h[1].value = "application/json";
+ nh = 2;
+ } else {
+ h[0].name = "x-api-key"; h[0].value = p->key;
+ h[1].name = "anthropic-version"; h[1].value = NET_API_VERSION;
+ h[2].name = "accept"; h[2].value = "application/json";
+ nh = 3;
+ }
+ http_build_request(&req, "GET", p->host, path.data, h, nh, NULL, 0);
+ buf_freezero(&authval); /* held the bearer token */
+
+ if (tlsconn_connect(&c, ns->tlscfg, p->host, p->port, &err) == -1) {
+ net_emit_error(ns, "connect", err);
+ free(err); /* tlsconn_connect hands ownership over */
+ goto out;
+ }
+ if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+ net_emit_error(ns, "write", "request failed");
+ goto close;
+ }
+
+ sink.buf = &body;
+ sink.cap = MODELS_MAXBODY;
+ sink.truncated = 0;
+ http_resp_init(&r, models_body, &sink);
+ for (;;) {
+ n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+ if (n == 0) {
+ http_resp_eof(&r);
+ break;
+ }
+ if (n == -1) {
+ net_emit_error(ns, "read", "connection failed");
+ http_resp_clear(&r);
+ goto close;
+ }
+ if (http_resp_push(&r, rbuf, (size_t)n) == -1) {
+ net_emit_error(ns, "http", "malformed response");
+ http_resp_clear(&r);
+ goto close;
+ }
+ if (http_resp_done(&r) || sink.truncated)
+ break; /* over the cap: stop reading this server */
+ }
+
+ if (r.status != 200) {
+ struct buf e;
+ int elen = body.len > 400 ? 400 : (int)body.len;
+
+ buf_init(&e);
+ buf_printf(&e, "models: HTTP %d: %.*s", r.status, elen,
+ body.data != NULL ? body.data : "");
+ net_emit(ns, M_ERROR, EP_UI, e.data, e.len);
+ buf_free(&e);
+ http_resp_clear(&r);
+ goto close;
+ }
+ if (sink.truncated) {
+ net_emit_error(ns, "models", "response too large");
+ http_resp_clear(&r);
+ goto close;
+ }
+
+ net_parse_models(body.data, body.len, out);
+ http_resp_clear(&r);
+ rc = 0;
+close:
+ tlsconn_close(&c);
+out:
+ buf_free(&path);
+ buf_freezero(&req); /* embeds the auth header; scrub it */
+ buf_free(&body);
+ return (rc);
+}
+
+/*
+ * The models endpoint sits beside the chat endpoint: derive it by swapping the
+ * chat path's final segment(s) for "models" (".../chat/completions" or
+ * ".../messages" -> ".../models"). Falls back to "/v1/models".
+ */
+static void
+net_models_path(struct net_provider *p, struct buf *out)
+{
+ const char *chat = p->path != NULL ? p->path :
+ p->ops->models_path();
+ const char *suffix = strstr(chat, "chat/completions") != NULL ?
+ "chat/completions" : "messages";
+ const char *at = strstr(chat, suffix);
+
+ if (at != NULL) {
+ buf_append(out, chat, (size_t)(at - chat));
+ buf_puts_cstr(out, "models");
+ } else
+ buf_puts_cstr(out, "/v1/models");
+ buf_terminate(out); /* NUL-terminate for http_build_request */
+}
+
+/*
+ * Parse a models-list response (both providers return {"data":[{"id":...}]})
+ * into out as newline-separated ids. Exposed for testing.
+ */
+void
+net_parse_models(const char *json, size_t len, struct buf *out)
+{
+ struct json_doc d;
+ int data, n, e, i;
+
+ if (json_parse(&d, json, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ return;
+ }
+ data = json_obj_get(&d, 0, "data");
+ if (data < 0 || json_tok_type(&d, data) != JSON_ARRAY) {
+ json_doc_free(&d);
+ return;
+ }
+ n = json_tok_size(&d, data);
+ e = data + 1;
+ for (i = 0; i < n; i++) {
+ if (json_tok_type(&d, e) == JSON_OBJECT) {
+ int id = json_obj_get(&d, e, "id");
+
+ if (id >= 0 && json_tok_type(&d, id) == JSON_STRING) {
+ char *s = json_tok_strdup(&d, id);
+
+ if (s != NULL) {
+ if (out->len > 0)
+ buf_putc(out, '\n');
+ buf_puts_cstr(out, s);
+ free(s);
+ }
+ }
+ }
+ e = json_skip(&d, e);
+ }
+ json_doc_free(&d);
+}
+
+/* ---- response pipeline trampolines ---- */
+
+static void
+net_body(const void *data, size_t len, void *arg)
+{
+ struct bodysink *bs = arg;
+
+ if (bs->r->status == 200)
+ (void)sse_push(bs->sse, data, len);
+ else
+ buf_append(&bs->err, data, len);
+}
+
+static void
+sse_to_provider(const char *event, const char *data, size_t datalen, void *arg)
+{
+ struct streamctx *sc = arg;
+
+ sc->prov->ops->stream_event(sc->ctx, event, data, datalen);
+}
+
+static void
+models_body(const void *data, size_t len, void *arg)
+{
+ struct models_sink *s = arg;
+
+ if (s->truncated)
+ return;
+ if (s->buf->len + len > s->cap) {
+ len = s->buf->len < s->cap ? s->cap - s->buf->len : 0;
+ s->truncated = 1;
+ }
+ if (len > 0)
+ buf_append(s->buf, data, len);
+}
+
+/* ---- semantic stream callbacks: relay to the UI and collect for the loop --- */
+
+static void
+on_text(const char *text, size_t len, void *arg)
+{
+ struct callctx *cc = arg;
+
+ if (!cc->ns->summarizing) /* a /compact reply is not transcript text */
+ net_emit(cc->ns, M_DELTA, EP_UI, text, len);
+ buf_append(&cc->tc->text, text, len);
+}
+
+static void
+on_tool_call(const char *id, const char *name, const char *input_json,
+ size_t len, void *arg)
+{
+ struct callctx *cc = arg;
+ struct turncollect *tc = cc->tc;
+ struct toolcall *t;
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id);
+ json_kv_string(&w, "name", name);
+ json_key(&w, "input");
+ json_raw_n(&w, input_json, len);
+ json_object_end(&w);
+ net_emit(cc->ns, M_TOOL_CALL, EP_UI, b.data, b.len);
+ buf_free(&b);
+
+ tc->tools = xreallocarray(tc->tools, tc->ntools + 1,
+ sizeof(*tc->tools));
+ t = &tc->tools[tc->ntools];
+ t->id = xstrdup(id);
+ t->name = xstrdup(name);
+ buf_init(&t->input);
+ /*
+ * NUL-terminate the stored input so later json_raw() readers can use
+ * it as a C string. buf_puts_cstr() copies strlen() bytes without writing
+ * a trailing NUL, leaving t->input.data[t->input.len] uninitialised.
+ * Without this, a parallel batch of tool calls bleeds garbage from
+ * adjacent heap into the assistant message's "input" field and
+ * downstream M_TOOL_REQUEST payloads -- exec then rejects every call
+ * as "malformed tool request".
+ */
+ buf_append(&t->input, input_json, len);
+ buf_terminate(&t->input);
+ tc->ntools++;
+}
+
+static void
+on_usage(long long in, long long out, void *arg)
+{
+ struct callctx *cc = arg;
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_int(&w, "input_tokens", in);
+ json_kv_int(&w, "output_tokens", out);
+ json_object_end(&w);
+ net_emit(cc->ns, M_USAGE, EP_UI, b.data, b.len);
+ buf_free(&b);
+
+ cc->tc->usage_in = in;
+ cc->tc->usage_out = out;
+}
+
+static void
+on_stop(const char *stop_reason, void *arg)
+{
+ struct callctx *cc = arg;
+
+ free(cc->tc->stop_reason);
+ cc->tc->stop_reason = xstrdup(stop_reason);
+}
+
+static void
+on_err(const char *type, const char *message, void *arg)
+{
+ struct callctx *cc = arg;
+
+ net_emit_error(cc->ns, type, message);
+ cc->tc->error = 1;
+}
+
+static void
+on_done(void *arg)
+{
+ (void)arg; /* the agent loop decides when the turn is complete */
+}
blob - /dev/null
blob + 20b4a015802d8832e4d9bf57a202b79fa08d7442 (mode 644)
--- /dev/null
+++ src/net/net_api.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_NET_API_H
+#define FUGU_NET_API_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "net_run.h"
+
+/*
+ * One tool_use block collected from a streamed assistant response. id and name
+ * are NUL-terminated strdup'd copies; input holds the raw JSON object as bytes.
+ */
+struct toolcall {
+ char *id;
+ char *name;
+ struct buf input;
+};
+
+/*
+ * Everything one streamed API response yields: assistant text, any tool_use
+ * blocks, usage counters, stop reason, and an error flag set when a fatal
+ * problem was already emitted to the UI. Allocated on the agent loop's stack
+ * and passed by pointer; turncollect_init / turncollect_free bracket its life.
+ */
+struct turncollect {
+ struct buf text;
+ struct toolcall *tools;
+ size_t ntools;
+ long long usage_in;
+ long long usage_out;
+ char *stop_reason;
+ int error;
+};
+
+void turncollect_init(struct turncollect *);
+void turncollect_free(struct turncollect *);
+
+/*
+ * One streamed API call against the active provider. Builds the request from
+ * conv/system/tools, decodes the SSE stream into out via the provider's ops,
+ * and emits live deltas to the UI. Returns 0 on transport success (including
+ * mid-stream parse failures, which set out->error) and -1 on connect/write/
+ * HTTP failure (an M_ERROR has already been emitted).
+ */
+int net_api_call(struct netstate *, struct turncollect *out,
+ struct buf *conv, const char *system, const char *tools);
+
+/*
+ * GET the provider's models endpoint, collect the body, and parse the ids into
+ * out as newline-separated strings. 0 on success; -1 (with an M_ERROR already
+ * emitted) on failure.
+ */
+int net_fetch_models(struct netstate *, struct net_provider *,
+ struct buf *out);
+
+/*
+ * Parse a provider models-list response ({"data":[{"id":...}]}) into out as
+ * newline-separated model ids. Exposed for testing.
+ */
+void net_parse_models(const char *json, size_t len, struct buf *out);
+
+#endif /* FUGU_NET_API_H */
blob - /dev/null
blob + ece65f6bc5280702212c708a5874131b26fac8d3 (mode 644)
--- /dev/null
+++ src/net/net_run.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The net worker's heart: an imsg loop holding the Anthropic key. For each
+ * submitted conversation it runs the agent loop -- call the Messages API
+ * (net_api.c), stream the reply to the UI, and while the model asks for tools,
+ * route each call to exec/fetch (via the parent), append the assistant tool_use
+ * and the user tool_result to the conversation, and call again -- until the
+ * model stops.
+ *
+ * Startup pledge: the caller has us at "stdio rpath inet dns" to read the CA
+ * bundle once; we then narrow to "stdio inet dns" and never touch a file again.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "net_api.h"
+#include "net_run.h"
+#include "openai.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "tlsconn.h"
+#include "util.h"
+
+#define NET_DEFAULT_MODEL "claude-sonnet-4-6"
+#define NET_DEFAULT_HOST "api.anthropic.com"
+#define NET_OPENAI_HOST "api.openai.com"
+#define NET_DEFAULT_PORT "443"
+#define NET_DEFAULT_CAFILE "/etc/ssl/cert.pem"
+#define NET_MAX_ITERS 50 /* tool-use rounds per turn */
+
+/*
+ * The tools the model may call; implementations live in the exec/fetch workers.
+ * The base (file/shell) tools are always offered; tools_schema() appends the web
+ * tools when web_search is enabled and the http_request tool when an http_allow
+ * list is configured. No fragment is a valid array on its own -- the closing
+ * ']' is added at assembly time.
+ */
+static const char TOOLS_BASE[] =
+"[{\"name\":\"read\",\"description\":\"Read a file's contents. Use an absolute "
+"or project-relative path. You must read a file before you can edit it. To "
+"list a directory, use ls, not read.\",\"input_schema\":{\"type\":\"object\","
+"\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the "
+"file.\"}},\"required\":[\"path\"]}},"
+"{\"name\":\"write\",\"description\":\"Create a new file or completely "
+"overwrite an existing one. To change part of an existing file use edit "
+"instead, which only replaces the matched text. Prefer editing an existing "
+"file over creating a new one; do not create documentation unless asked.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":"
+"\"string\"},\"content\":{\"type\":\"string\"}},\"required\":[\"path\","
+"\"content\"]}},"
+"{\"name\":\"edit\",\"description\":\"Replace one exact occurrence of old_string "
+"with new_string in a file. You must read the file first. old_string must "
+"match the file byte-for-byte, including indentation, and must be unique -- "
+"include just enough surrounding lines to make it so, or the edit fails. Keep "
+"old_string minimal. Use write to create a new file.\",\"input_schema\":{"
+"\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},"
+"\"old_string\":{\"type\":\"string\"},\"new_string\":{\"type\":\"string\"}},"
+"\"required\":[\"path\",\"old_string\",\"new_string\"]}},"
+"{\"name\":\"shell\",\"description\":\"Run a command with /bin/ksh -c in the "
+"project directory and return its combined stdout and stderr. There is NO "
+"network access -- installs, fetches, and pushes fail; use web_fetch or "
+"web_search for the network. Prefer the dedicated tools (read, grep, find, ls) "
+"over their shell equivalents. Be careful with destructive commands.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"command\":{\"type\":\"string\",\"description\":\"The shell command to "
+"run.\"}},\"required\":[\"command\"]}},"
+"{\"name\":\"grep\",\"description\":\"Search file contents for a regular "
+"expression (grep -RnI), returning matching lines with file and line numbers. "
+"Use this to find where something is defined or used. To find files by name, "
+"use find instead.\",\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"pattern\":{\"type\":\"string\",\"description\":\"The regular expression to "
+"search for.\"},\"path\":{\"type\":\"string\",\"description\":\"File or "
+"directory to search; defaults to the current directory.\"}},\"required\":["
+"\"pattern\"]}},"
+"{\"name\":\"find\",\"description\":\"List files under a directory, optionally "
+"filtered by a -name glob. Use to locate files by name; to search inside "
+"files, use grep.\",\"input_schema\":{\"type\":\"object\","
+"\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Directory to "
+"search; defaults to the current directory.\"},\"name\":{\"type\":\"string\","
+"\"description\":\"Optional -name glob pattern, e.g. '*.c'.\"}}}},"
+"{\"name\":\"ls\",\"description\":\"List directory contents (ls -la), including "
+"permissions and sizes. read cannot list directories.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":"
+"\"string\",\"description\":\"File or directory to list; defaults to the "
+"current directory.\"}}}}";
+
+/* Appended to TOOLS_BASE when web_search is enabled; note the leading comma. */
+static const char TOOLS_WEB[] =
+",{\"name\":\"web_search\",\"description\":\"Search the web via Kagi and return "
+"result titles, URLs, and snippets. Use for current information or docs beyond "
+"your knowledge; follow a result with web_fetch to read it.\",\"input_schema\":"
+"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\","
+"\"description\":\"The search query.\"}},\"required\":[\"query\"]}},"
+"{\"name\":\"web_fetch\",\"description\":\"Fetch an http(s) URL and return its "
+"readable text (HTML converted to plain text). Read-only; cannot reach "
+"authenticated or private pages.\",\"input_schema\":{\"type\":"
+"\"object\",\"properties\":{\"url\":{\"type\":\"string\",\"description\":\"The "
+"absolute http or https URL to fetch.\"}},\"required\":[\"url\"]}}";
+
+/*
+ * Appended when http_request is enabled (an http_allow list is configured).
+ * A general HTTPS client the model drives: arbitrary method, model-supplied
+ * headers, and a body. The fetch worker enforces the host allow/block lists,
+ * so the description tells the model the reachable set is restricted.
+ */
+static const char TOOLS_HTTP[] =
+",{\"name\":\"http_request\",\"description\":\"Make an HTTPS request to an "
+"allowed host (the operator restricts which hosts are reachable; you will get "
+"an error otherwise). Use for JSON/REST APIs. Returns the status line and "
+"response body.\",\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"url\":{\"type\":\"string\",\"description\":\"Absolute https URL.\"},"
+"\"method\":{\"type\":\"string\",\"description\":\"HTTP method; defaults to "
+"GET.\"},\"headers\":{\"type\":\"object\",\"description\":\"Optional request "
+"headers as a name/value object.\"},\"body\":{\"type\":\"string\","
+"\"description\":\"Optional request body (e.g. a JSON string).\"}},"
+"\"required\":[\"url\"]}}";
+
+/* Assemble the tool list: base tools always, web/http tools when enabled. */
+void
+tools_schema(struct buf *b, int web_search, int http_request, const char *extra)
+{
+ buf_puts_cstr(b, TOOLS_BASE);
+ if (web_search)
+ buf_puts_cstr(b, TOOLS_WEB);
+ if (http_request)
+ buf_puts_cstr(b, TOOLS_HTTP);
+ if (extra != NULL && *extra != '\0')
+ buf_puts_cstr(b, extra); /* leading comma is part of the fragment */
+ buf_putc(b, ']');
+ buf_putc(b, '\0'); /* a C-string for the request builder */
+}
+
+/*
+ * Default system prompt, sent on every normal turn unless the config supplies
+ * its own `system`. Kept short and provider-neutral: identity, the terse-tone
+ * doctrine, scope/style defaults that suit a minimal agent, tool guidance, and
+ * a one-line refusal posture.
+ */
+static const char NET_DEFAULT_SYSTEM[] =
+"You are fugu, a terminal coding assistant. You work on a Unix system, "
+"following OpenBSD conventions where they apply, in any language or stack.\n"
+"\n"
+"Tone. Be terse. No preamble or postamble -- do not restate the question or "
+"close with \"let me know if...\". Lead with the answer or outcome; add "
+"reasoning only when it changes what the reader does next. Be short by saying "
+"less, not by writing in fragments or arrow-chains -- use complete sentences. "
+"A simple question gets a one-line answer, not headings. Your output is "
+"rendered as Markdown in a terminal; reference code as file:line. No emojis "
+"unless asked.\n"
+"\n"
+"Doing tasks. Do what is asked -- no extra features, refactors, or "
+"abstractions. Match the surrounding code's style, naming, and idioms, and "
+"check a library is already used before reaching for it. Do not add comments "
+"unless the reason behind the code is non-obvious; never narrate what the code "
+"does. Delete dead code rather than leaving compatibility shims or "
+"commented-out blocks. Do not create documentation unless asked.\n"
+"\n"
+"Tools. Prefer the dedicated tools (read, grep, find, ls) over the shell, and "
+"run independent calls together. Read a file before editing it. Looking is "
+"free -- read and search freely -- but confirm before destructive or "
+"hard-to-reverse changes. Report results honestly: if a build or test fails, "
+"say so with the output.\n"
+"\n"
+"Security. Help with defensive security, authorized testing, and analysis. "
+"Refuse to build malware, mass-targeting, or destructive tooling.";
+
+/* System prompt for /compact: one tool-less call that condenses the history. */
+static const char SUMMARY_SYSTEM[] =
+"You are condensing a software-engineering chat session so it can continue with "
+"a smaller context window. Write a concise but complete summary that preserves "
+"the user's goals and constraints, the key decisions and their rationale, the "
+"current state of the work, the important file paths and identifiers, and any "
+"open questions or next steps. Address it to yourself as notes for continuing "
+"the conversation. Output only the summary, with no preamble or questions.";
+
+/* Empty tool list: the summarisation call must not invoke any tools. */
+static const char SUMMARY_TOOLS[] = "[]";
+
+static void net_apply_config(struct netstate *, const void *, size_t);
+static void net_ensure_ready(struct netstate *);
+static void net_run_turn(struct netstate *, const char *, size_t);
+static void net_summarize(struct netstate *, const char *, size_t);
+static void net_list_models(struct netstate *);
+static void net_set_model(struct netstate *, const void *, size_t);
+static void net_providers_free(struct netstate *);
+static void net_apply_providers(struct netstate *, struct json_doc *, int);
+static void net_state_clear(struct netstate *);
+static char *obj_str(struct json_doc *, int obj, const char *key);
+static char *cfg_str(struct json_doc *, const char *);
+
+static void net_models_append(struct buf *, const char *, const struct buf *,
+ int tag);
+
+static void conv_append(struct buf *, const struct buf *);
+static void build_assistant_msg(struct buf *, const struct turncollect *);
+static void build_tool_results(struct netstate *, const struct turncollect *,
+ struct buf *);
+static int send_tool_and_wait(struct netstate *, const struct toolcall *,
+ struct buf *, int *);
+static uint32_t tool_dst(const char *);
+
+int
+net_loop(struct imsgproto *ip)
+{
+ struct netstate ns;
+ struct fugu_msg m;
+ int r, n;
+
+ memset(&ns, 0, sizeof(ns));
+ ns.ip = ip;
+ ns.max_tokens = 4096;
+ ns.web_search = 1; /* offered unless the config disables it */
+ ns.cafile = xstrdup(NET_DEFAULT_CAFILE);
+ /*
+ * Start with a single anthropic "default" provider so the worker is
+ * well-formed before any M_CONFIG; the config replaces this array.
+ */
+ ns.providers = xreallocarray(NULL, 1, sizeof(*ns.providers));
+ memset(&ns.providers[0], 0, sizeof(ns.providers[0]));
+ ns.providers[0].name = xstrdup("default");
+ ns.providers[0].type = LLM_ANTHROPIC;
+ ns.providers[0].ops = &anthropic_ops;
+ ns.providers[0].model = xstrdup(NET_DEFAULT_MODEL);
+ ns.providers[0].host = xstrdup(NET_DEFAULT_HOST);
+ ns.providers[0].port = xstrdup(NET_DEFAULT_PORT);
+ ns.nprov = 1;
+ ns.active = 0;
+
+ if (pledge("stdio rpath inet dns", NULL) == -1)
+ fatal("pledge");
+
+ net_emit(&ns, M_READY, EP_PARENT, NULL, 0);
+
+ for (;;) {
+ r = ip_get(ip, &m);
+ if (r == -1) {
+ log_warnx("net: imsg protocol error");
+ break;
+ }
+ if (r == 0) {
+ if ((n = ip_read(ip)) <= 0)
+ break;
+ continue;
+ }
+
+ switch (m.type) {
+ case M_SECRET: {
+ /* Payload: uint32 provider index (host order) + key bytes. */
+ uint32_t idx;
+ size_t klen;
+
+ if (m.len < sizeof(idx))
+ break;
+ memcpy(&idx, m.data, sizeof(idx));
+ if (idx >= ns.nprov)
+ break; /* config not yet applied / bad idx */
+ klen = m.len - sizeof(idx);
+ if (ns.providers[idx].key != NULL)
+ freezero(ns.providers[idx].key,
+ strlen(ns.providers[idx].key));
+ ns.providers[idx].key = xmalloc(klen + 1);
+ memcpy(ns.providers[idx].key,
+ (char *)m.data + sizeof(idx), klen);
+ ns.providers[idx].key[klen] = '\0';
+ break;
+ }
+ case M_CONFIG:
+ net_apply_config(&ns, (char *)m.data, m.len);
+ net_ensure_ready(&ns);
+ break;
+ case M_SUBMIT:
+ net_run_turn(&ns, (char *)m.data, m.len);
+ break;
+ case M_SUMMARIZE:
+ net_summarize(&ns, (char *)m.data, m.len);
+ break;
+ case M_MODELS:
+ net_list_models(&ns);
+ break;
+ case M_SET_MODEL:
+ net_set_model(&ns, m.data, m.len);
+ break;
+ case M_TOOLS_ADD:
+ /*
+ * Register an additional tool fragment (currently sent
+ * once by ui at startup with the `skill` tool def). The
+ * payload is a JSON fragment that splices into the tools
+ * array, comma-prefixed and bracket-less. Replaces any
+ * prior fragment -- ui owns the registry, not us.
+ */
+ free(ns.extra_tools);
+ if (m.len > 0) {
+ ns.extra_tools = xmalloc(m.len + 1);
+ memcpy(ns.extra_tools, m.data, m.len);
+ ns.extra_tools[m.len] = '\0';
+ } else {
+ ns.extra_tools = NULL;
+ }
+ break;
+ case M_SHUTDOWN:
+ ip_msg_free(&m);
+ goto out;
+ default:
+ break;
+ }
+ ip_msg_free(&m);
+ if (ns.aborted)
+ break;
+ }
+out:
+ net_state_clear(&ns);
+ return (0);
+}
+
+/*
+ * The agent loop: call the API, and while the model requests tools, run them
+ * and call again, accumulating the whole exchange in conv.
+ */
+static void
+net_run_turn(struct netstate *ns, const char *messages, size_t mlen)
+{
+ struct buf conv, tools;
+ struct net_provider *p = net_active(ns);
+ const char *system;
+ int iter;
+
+ net_ensure_ready(ns);
+ if (ns->tlscfg == NULL) {
+ net_emit_error(ns, "tls", "no CA configured");
+ return;
+ }
+ if (p == NULL || p->key == NULL) {
+ net_emit_error(ns, "auth", "no API key");
+ return;
+ }
+
+ /* The config's system prompt overrides fugu's built-in default. */
+ system = ns->system != NULL ? ns->system : NET_DEFAULT_SYSTEM;
+
+ buf_init(&conv);
+ buf_append(&conv, messages, mlen);
+ buf_init(&tools);
+ tools_schema(&tools, ns->web_search, ns->http_request, ns->extra_tools);
+
+ for (iter = 0; iter < NET_MAX_ITERS; iter++) {
+ struct turncollect tc;
+ struct buf am, um;
+
+ turncollect_init(&tc);
+
+ (void)net_api_call(ns, &tc, &conv, system, tools.data);
+
+ if (tc.error) {
+ turncollect_free(&tc);
+ break;
+ }
+ /*
+ * Record the assistant message (text + any tool_use): send it to
+ * the UI for its conversation + journal, and add it to the running
+ * projection.
+ */
+ buf_init(&am);
+ build_assistant_msg(&am, &tc);
+ net_emit(ns, M_MESSAGE, EP_UI, am.data, am.len);
+ conv_append(&conv, &am);
+ buf_free(&am);
+
+ if (tc.ntools == 0) { /* model is done */
+ const char *sr = tc.stop_reason != NULL ?
+ tc.stop_reason : "end_turn";
+
+ net_emit(ns, M_TURN_DONE, EP_UI, sr, strlen(sr));
+ turncollect_free(&tc);
+ break;
+ }
+
+ /* Run the tools and record the tool_result message likewise. */
+ buf_init(&um);
+ build_tool_results(ns, &tc, &um);
+ net_emit(ns, M_MESSAGE, EP_UI, um.data, um.len);
+ conv_append(&conv, &um);
+ buf_free(&um);
+
+ turncollect_free(&tc);
+ if (ns->aborted)
+ break;
+ }
+
+ if (iter >= NET_MAX_ITERS) {
+ net_emit_error(ns, "loop", "tool iteration limit reached");
+ net_emit(ns, M_TURN_DONE, EP_UI, "max_iterations",
+ strlen("max_iterations"));
+ }
+ buf_free(&conv);
+ buf_free(&tools);
+}
+
+/*
+ * /compact: one tool-less Messages call that summarises the conversation. The
+ * UI sends the same projection it would M_SUBMIT; we append a final user turn
+ * asking for the summary, run a single call with the summary system prompt and
+ * no tools (deltas hushed), and return the collected text as M_SUMMARY. On any
+ * error net_api_call has already emitted M_ERROR and the UI keeps its context.
+ */
+static void
+net_summarize(struct netstate *ns, const char *messages, size_t mlen)
+{
+ struct turncollect tc;
+ struct json_writer w;
+ struct buf conv, instr;
+ struct net_provider *p = net_active(ns);
+
+ net_ensure_ready(ns);
+ if (ns->tlscfg == NULL) {
+ net_emit_error(ns, "tls", "no CA configured");
+ return;
+ }
+ if (p == NULL || p->key == NULL) {
+ net_emit_error(ns, "auth", "no API key");
+ return;
+ }
+
+ buf_init(&conv);
+ buf_append(&conv, messages, mlen);
+
+ /* Append "summarise it" as a closing user turn so the model replies. */
+ buf_init(&instr);
+ json_writer_init(&w, &instr);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "user");
+ json_kv_string(&w, "content", "Summarize our conversation so far, "
+ "following your instructions.");
+ json_object_end(&w);
+ conv_append(&conv, &instr);
+ buf_free(&instr);
+
+ turncollect_init(&tc);
+ ns->summarizing = 1;
+ (void)net_api_call(ns, &tc, &conv, SUMMARY_SYSTEM, SUMMARY_TOOLS);
+ ns->summarizing = 0;
+
+ if (!tc.error)
+ net_emit(ns, M_SUMMARY, EP_UI, tc.text.data, tc.text.len);
+
+ turncollect_free(&tc);
+ buf_free(&conv);
+}
+
+/*
+ * /model: fetch every configured provider's model ids and reply to the UI with
+ * a newline-separated list (M_MODELS). With more than one provider each id is
+ * tagged "name\tmodel" so the picker (and net_set_model) can route the choice;
+ * with one provider the ids are bare, exactly as before. Always replies -- an
+ * empty list on total failure -- so the UI can clear its loading state.
+ */
+static void
+net_list_models(struct netstate *ns)
+{
+ struct buf out;
+ size_t i;
+ int tag, anykey = 0;
+
+ buf_init(&out);
+ net_ensure_ready(ns);
+ tag = ns->nprov > 1;
+ if (ns->tlscfg == NULL)
+ net_emit_error(ns, "tls", "no CA configured");
+ else {
+ for (i = 0; i < ns->nprov; i++) {
+ struct net_provider *p = &ns->providers[i];
+ struct buf one;
+
+ if (p->key == NULL)
+ continue; /* a keyless provider lists nothing */
+ anykey = 1;
+ buf_init(&one);
+ if (net_fetch_models(ns, p, &one) == 0)
+ net_models_append(&out, p->name, &one, tag);
+ buf_free(&one);
+ }
+ if (!anykey)
+ net_emit_error(ns, "auth", "no API key");
+ }
+
+ net_emit(ns, M_MODELS, EP_UI, out.data, out.len);
+ buf_free(&out);
+}
+
+/*
+ * Append one provider's models (newline-separated ids in `ids`) to `out`, each
+ * prefixed with "name\t" when tag is set, keeping out newline-separated.
+ */
+static void
+net_models_append(struct buf *out, const char *name, const struct buf *ids,
+ int tag)
+{
+ const char *d = ids->data;
+ size_t start, i;
+
+ for (start = 0, i = 0; i <= ids->len; i++) {
+ if (i == ids->len || d[i] == '\n') {
+ if (i > start) {
+ if (out->len > 0)
+ buf_putc(out, '\n');
+ if (tag) {
+ buf_puts_cstr(out, name);
+ buf_putc(out, '\t');
+ }
+ buf_append(out, d + start, i - start);
+ }
+ start = i + 1;
+ }
+ }
+}
+
+/*
+ * Switch the provider/model used for subsequent turns. The payload is either a
+ * bare model id (set the active provider's model) or "name\tmodel" (switch the
+ * active provider to the named one, then set its model) -- the form the picker
+ * sends when several providers are configured.
+ */
+static void
+net_set_model(struct netstate *ns, const void *data, size_t len)
+{
+ const char *d = data, *tab;
+ struct net_provider *p;
+ size_t modoff = 0, modlen, i;
+
+ if (len == 0 || ns->nprov == 0)
+ return;
+ if ((tab = memchr(d, '\t', len)) != NULL) {
+ size_t namelen = (size_t)(tab - d);
+
+ for (i = 0; i < ns->nprov; i++)
+ if (strlen(ns->providers[i].name) == namelen &&
+ memcmp(ns->providers[i].name, d, namelen) == 0) {
+ ns->active = i;
+ break;
+ }
+ modoff = namelen + 1;
+ }
+ if (modoff > len)
+ return;
+ modlen = len - modoff;
+ if (modlen == 0)
+ return;
+ p = &ns->providers[ns->active];
+ free(p->model);
+ p->model = xmalloc(modlen + 1);
+ memcpy(p->model, d + modoff, modlen);
+ p->model[modlen] = '\0';
+}
+
+/* Append one message object to a JSON messages array that ends in ']'. */
+static void
+conv_append(struct buf *conv, const struct buf *msg)
+{
+ conv->len--; /* drop the trailing ']' */
+ buf_putc(conv, ',');
+ buf_append(conv, msg->data, msg->len);
+ buf_putc(conv, ']');
+}
+
+static void
+build_assistant_msg(struct buf *out, const struct turncollect *tc)
+{
+ struct json_writer w;
+ size_t i;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "assistant");
+ json_key(&w, "content");
+ json_array_start(&w);
+ if (tc->text.len > 0) {
+ json_object_start(&w);
+ json_kv_string(&w, "type", "text");
+ json_key(&w, "text");
+ json_string_n(&w, tc->text.data, tc->text.len);
+ json_object_end(&w);
+ }
+ for (i = 0; i < tc->ntools; i++) {
+ json_object_start(&w);
+ json_kv_string(&w, "type", "tool_use");
+ json_kv_string(&w, "id", tc->tools[i].id);
+ json_kv_string(&w, "name", tc->tools[i].name);
+ json_key(&w, "input");
+ json_raw_n(&w, tc->tools[i].input.data,
+ tc->tools[i].input.len);
+ json_object_end(&w);
+ }
+ json_array_end(&w);
+ json_object_end(&w);
+}
+
+static void
+build_tool_results(struct netstate *ns, const struct turncollect *tc,
+ struct buf *out)
+{
+ struct json_writer w;
+ size_t i;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "user");
+ json_key(&w, "content");
+ json_array_start(&w);
+ for (i = 0; i < tc->ntools; i++) {
+ struct buf content;
+ int is_error = 0;
+
+ buf_init(&content);
+ if (send_tool_and_wait(ns, &tc->tools[i], &content,
+ &is_error) == -1) {
+ ns->aborted = 1;
+ buf_free(&content);
+ break;
+ }
+ json_object_start(&w);
+ json_kv_string(&w, "type", "tool_result");
+ json_kv_string(&w, "tool_use_id", tc->tools[i].id);
+ json_key(&w, "content");
+ json_string_n(&w, content.len > 0 ? content.data : "",
+ content.len);
+ json_kv_bool(&w, "is_error", is_error);
+ json_object_end(&w);
+ buf_free(&content);
+ }
+ json_array_end(&w);
+ json_object_end(&w);
+}
+
+/* Send a tool request to its worker and block for the result. 0 / -1. */
+static int
+send_tool_and_wait(struct netstate *ns, const struct toolcall *tool,
+ struct buf *content, int *is_error)
+{
+ struct json_writer w;
+ struct buf req;
+ struct fugu_msg m;
+ int r;
+
+ buf_init(&req);
+ json_writer_init(&w, &req);
+ json_object_start(&w);
+ json_kv_string(&w, "id", tool->id);
+ json_kv_string(&w, "name", tool->name);
+ json_key(&w, "input");
+ json_raw_n(&w, tool->input.data, tool->input.len);
+ json_object_end(&w);
+
+ r = (ip_compose(ns->ip, M_TOOL_REQUEST, tool_dst(tool->name), EP_NET,
+ req.data, req.len) != -1 && ip_flush(ns->ip) != -1) ? 0 : -1;
+ buf_free(&req);
+ if (r == -1)
+ return (-1);
+
+ for (;;) {
+ r = ip_get(ns->ip, &m);
+ if (r == -1)
+ return (-1);
+ if (r == 0) {
+ if (ip_read(ns->ip) <= 0)
+ return (-1);
+ continue;
+ }
+ if (m.type == M_TOOL_RESULT) {
+ struct json_doc d;
+ int t;
+
+ if (json_parse(&d, m.data, m.len) == 0 && d.ntok >= 1) {
+ if ((t = json_obj_get(&d, 0, "content")) >= 0) {
+ size_t clen = 0;
+ char *c = json_tok_strdup_n(&d, t, &clen);
+
+ if (c != NULL) {
+ buf_append(content, c, clen);
+ free(c);
+ }
+ }
+ t = json_obj_get(&d, 0, "is_error");
+ *is_error = (t >= 0 &&
+ json_tok_streq(&d, t, "true"));
+ }
+ json_doc_free(&d);
+ ip_msg_free(&m);
+ return (0);
+ }
+ if (m.type == M_SHUTDOWN) {
+ ip_msg_free(&m);
+ return (-1);
+ }
+ ip_msg_free(&m); /* ignore anything else mid-turn */
+ }
+}
+
+static uint32_t
+tool_dst(const char *name)
+{
+ if (strcmp(name, "web_search") == 0 || strcmp(name, "web_fetch") == 0 ||
+ strcmp(name, "http_request") == 0)
+ return (EP_FETCH);
+ if (strcmp(name, "skill") == 0)
+ return (EP_UI); /* skills are serviced by fugu-ui */
+ return (EP_EXEC);
+}
+
+void
+net_emit(struct netstate *ns, uint32_t type, uint32_t dst, const void *data,
+ size_t len)
+{
+ if (ip_compose(ns->ip, type, dst, EP_NET, data, len) == -1 ||
+ ip_flush(ns->ip) == -1)
+ fatalx("net: failed to send imsg");
+}
+
+void
+net_emit_error(struct netstate *ns, const char *what, const char *detail)
+{
+ struct buf b;
+
+ buf_init(&b);
+ buf_printf(&b, "%s: %s", what, detail != NULL ? detail : "error");
+ net_emit(ns, M_ERROR, EP_UI, b.data, b.len);
+ buf_free(&b);
+}
+
+static void
+net_apply_config(struct netstate *ns, const void *data, size_t len)
+{
+ struct json_doc d;
+ const char *errstr;
+ char *s;
+ int t;
+
+ if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ log_warnx("net: malformed config");
+ return;
+ }
+ if ((t = json_obj_get(&d, 0, "max_tokens")) >= 0) {
+ char *v = json_tok_strdup(&d, t);
+ long long mx;
+
+ if (v != NULL) {
+ mx = strtonum(v, 1, 1000000, &errstr);
+ if (errstr == NULL)
+ ns->max_tokens = (int)mx;
+ free(v);
+ }
+ }
+ if ((t = json_obj_get(&d, 0, "web_search")) >= 0)
+ ns->web_search = json_tok_streq(&d, t, "true");
+ if ((t = json_obj_get(&d, 0, "http_request")) >= 0)
+ ns->http_request = json_tok_streq(&d, t, "true");
+ if ((s = cfg_str(&d, "system")) != NULL) {
+ free(ns->system);
+ ns->system = s;
+ }
+ if ((s = cfg_str(&d, "cafile")) != NULL) {
+ free(ns->cafile);
+ ns->cafile = s;
+ }
+ /* The provider array (and "active" index) replaces the worker's set. */
+ if ((t = json_obj_get(&d, 0, "providers")) >= 0 &&
+ json_tok_type(&d, t) == JSON_ARRAY) {
+ long long active = 0;
+ int a;
+
+ net_apply_providers(ns, &d, t);
+ if ((a = json_obj_get(&d, 0, "active")) >= 0) {
+ char *v = json_tok_strdup(&d, a);
+
+ if (v != NULL) {
+ active = strtonum(v, 0, 1000000, &errstr);
+ if (errstr != NULL)
+ active = 0;
+ free(v);
+ }
+ }
+ ns->active = ((size_t)active < ns->nprov) ? (size_t)active : 0;
+ }
+ json_doc_free(&d);
+}
+
+/*
+ * Rebuild ns->providers from a config "providers" array. Each element supplies
+ * name/type and optional model/host/port/path; missing fields fall back to the
+ * type's defaults. Keys are not here -- they arrive separately as M_SECRET.
+ */
+static void
+net_apply_providers(struct netstate *ns, struct json_doc *d, int arr)
+{
+ struct net_provider *np;
+ int n = json_tok_size(d, arr), e = arr + 1, i;
+
+ if (n <= 0)
+ return; /* keep the existing set rather than empty */
+ net_providers_free(ns);
+ np = xreallocarray(NULL, (size_t)n, sizeof(*np));
+ for (i = 0; i < n; i++) {
+ if (json_tok_type(d, e) == JSON_OBJECT) {
+ struct net_provider *p = &np[ns->nprov];
+ char *ty;
+
+ memset(p, 0, sizeof(*p));
+ if ((p->name = obj_str(d, e, "name")) == NULL)
+ p->name = xstrdup("default");
+ ty = obj_str(d, e, "type");
+ p->type = (ty != NULL && strcmp(ty, "openai") == 0) ?
+ LLM_OPENAI : LLM_ANTHROPIC;
+ free(ty);
+ p->ops = (p->type == LLM_OPENAI) ?
+ &openai_ops : &anthropic_ops;
+ if ((p->model = obj_str(d, e, "model")) == NULL)
+ p->model = xstrdup(NET_DEFAULT_MODEL);
+ if ((p->host = obj_str(d, e, "host")) == NULL)
+ p->host = xstrdup(p->type == LLM_OPENAI ?
+ NET_OPENAI_HOST : NET_DEFAULT_HOST);
+ if ((p->port = obj_str(d, e, "port")) == NULL)
+ p->port = xstrdup(NET_DEFAULT_PORT);
+ p->path = obj_str(d, e, "path"); /* NULL = default */
+ ns->nprov++;
+ }
+ e = json_skip(d, e);
+ }
+ ns->providers = np;
+ ns->active = 0;
+}
+
+struct net_provider *
+net_active(struct netstate *ns)
+{
+ if (ns->nprov == 0)
+ return (NULL);
+ if (ns->active >= ns->nprov)
+ ns->active = 0;
+ return (&ns->providers[ns->active]);
+}
+
+static void
+net_providers_free(struct netstate *ns)
+{
+ size_t i;
+
+ for (i = 0; i < ns->nprov; i++) {
+ struct net_provider *p = &ns->providers[i];
+
+ if (p->key != NULL)
+ freezero(p->key, strlen(p->key));
+ free(p->name);
+ free(p->model);
+ free(p->host);
+ free(p->port);
+ free(p->path);
+ }
+ free(ns->providers);
+ ns->providers = NULL;
+ ns->nprov = 0;
+ ns->active = 0;
+}
+
+static void
+net_ensure_ready(struct netstate *ns)
+{
+ if (ns->ready)
+ return;
+ ns->tlscfg = tlsconn_client_config(ns->cafile);
+ if (ns->tlscfg == NULL)
+ log_warnx("net: could not load CA bundle %s", ns->cafile);
+ if (pledge("stdio inet dns", NULL) == -1)
+ fatal("pledge");
+ ns->ready = 1;
+}
+
+static void
+net_state_clear(struct netstate *ns)
+{
+ net_providers_free(ns);
+ free(ns->system);
+ free(ns->extra_tools);
+ free(ns->cafile);
+ if (ns->tlscfg != NULL)
+ tls_config_free(ns->tlscfg);
+ memset(ns, 0, sizeof(*ns));
+}
+
+/* A string-valued child `key` of object token `obj`, strdup'd, or NULL. */
+static char *
+obj_str(struct json_doc *d, int obj, const char *key)
+{
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0 || json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
+
+static char *
+cfg_str(struct json_doc *d, const char *key)
+{
+ return (obj_str(d, 0, key));
+}
blob - /dev/null
blob + 6ae66cfbc835d27a17c1520fb0e4f13ebf9da3db (mode 644)
--- /dev/null
+++ src/net/net_run.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_NET_RUN_H
+#define FUGU_NET_RUN_H
+
+#include <sys/types.h>
+
+#include <stdint.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "provider.h"
+
+struct llm_provider_ops; /* provider_ops.h */
+
+/*
+ * One configured model provider. fugu can hold several at once (an Anthropic
+ * key and an OpenAI key, say); ns->active selects the one driving turns, and
+ * /model switches it. Each carries its own endpoint and credential; the key
+ * is the only secret and is freezero()d on replacement or teardown.
+ */
+struct net_provider {
+ char *name; /* config name, e.g. "default" */
+ enum llm_provider type; /* wire protocol: anthropic|openai */
+ char *key; /* API credential (secret) or NULL */
+ char *model;
+ char *host;
+ char *port;
+ char *path; /* request-path override, else default */
+ const struct llm_provider_ops *ops; /* resolved at config time */
+};
+
+/*
+ * The net worker's state across an imsg session: the imsg channel, the set of
+ * configured providers (and which one is active), the agent's tunables, the
+ * TLS client config, and a few transient flags the inner loop watches. The
+ * agent-loop modules read it directly; the transport seam (net_api.c) reads
+ * tlscfg / active and writes nothing.
+ */
+struct netstate {
+ struct imsgproto *ip;
+ struct net_provider *providers; /* one or more configured */
+ size_t nprov;
+ size_t active; /* index of the driving provider */
+ int max_tokens;
+ int web_search; /* offer web_search/web_fetch */
+ int http_request; /* offer http_request tool */
+ char *extra_tools; /* JSON fragment from M_TOOLS_ADD */
+ char *system;
+ char *cafile;
+ struct tls_config *tlscfg;
+ int ready;
+ int aborted; /* shutdown seen mid-turn */
+ int summarizing; /* /compact call: hush deltas */
+};
+
+/*
+ * Run the net worker loop on an already-connected imsg channel to the parent.
+ * Pledges down to "stdio inet dns" once the CA bundle is loaded, then services
+ * M_SECRET / M_CONFIG / M_SUBMIT until M_SHUTDOWN or the channel closes.
+ * Returns 0 on a clean exit.
+ */
+int net_loop(struct imsgproto *);
+
+/*
+ * Build the Messages API tool list into b (NUL-terminated): the base file/shell
+ * tools always, plus web_search/web_fetch when web_search is nonzero, and the
+ * general http_request tool when http_request is nonzero, and any extra tool
+ * fragments registered via M_TOOLS_ADD (comma-prefixed, no enclosing brackets)
+ * when `extra` is non-NULL. Exposed for testing.
+ */
+void tools_schema(struct buf *b, int web_search, int http_request,
+ const char *extra);
+
+/* The active provider, or NULL when none is configured. */
+struct net_provider *net_active(struct netstate *);
+
+/* Send one imsg from the net worker; fatal on send failure. */
+void net_emit(struct netstate *, uint32_t type, uint32_t dst,
+ const void *data, size_t len);
+
+/* Emit an M_ERROR to the UI of the form "what: detail". */
+void net_emit_error(struct netstate *, const char *what, const char *detail);
+
+#endif /* FUGU_NET_RUN_H */
blob - /dev/null
blob + 4a96aa04bb1671f88a9d92818faf60d401eedfe6 (mode 644)
--- /dev/null
+++ src/parent/Makefile
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG= fugu
+SRCS+= parent.c conf.c confload.c parse.y
+BINDIR= ${PREFIX}/bin
+CPPFLAGS+= -DFUGU_LIBEXEC=\"${PREFIX}/libexec/fugu\"
+
+# The user-facing program carries both manuals; sources live in ../../man.
+MAN= fugu.1 fugu.conf.5
+MANDIR= ${PREFIX}/man/man
+.PATH: ${.CURDIR}/../../man
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + b3a268bbd778f327dfe05f3dd4dd3520f7ce3e2a (mode 644)
--- /dev/null
+++ src/parent/parent.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu: the parent / coordinator process (installed as ${PREFIX}/bin/fugu).
+ *
+ * - reads /etc/fugu.conf (running setgid _fugu, the only privilege fugu holds)
+ * and resolves the effective config for the invoking uid (conf_load);
+ * - fork+execs the ui and net helpers, each over an imsg(3) socketpair handed
+ * to the child on fd 3;
+ * - delegates each provider's key to net (M_SECRET) and the non-secret
+ * settings (M_CONFIG), then freezero()s its copy;
+ * - drops the setgid _fugu group once the secrets are delegated;
+ * - relays every worker<->worker imsg by destination id, never reassembling a
+ * payload, and never giving one worker a channel to another;
+ * - pledges "stdio proc" once the helpers are spawned.
+ *
+ * Helper paths default to ${FUGU_LIBEXEC} and may be overridden per worker via
+ * the environment (used by the test harness).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "conf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "util.h"
+
+#ifndef FUGU_LIBEXEC
+#define FUGU_LIBEXEC "/usr/local/libexec/fugu"
+#endif
+
+#define IMSG_CHILD_FD 3
+
+enum { W_UI, W_NET, W_EXEC, W_FETCH, NWORKERS };
+
+struct wdesc {
+ const char *name;
+ uint32_t ep;
+ const char *env_bin;
+ const char *def_bin;
+};
+
+static const struct wdesc WDESC[NWORKERS] = {
+ { "ui", EP_UI, "FUGU_UI_BIN", FUGU_LIBEXEC "/fugu-ui" },
+ { "net", EP_NET, "FUGU_NET_BIN", FUGU_LIBEXEC "/fugu-net" },
+ { "exec", EP_EXEC, "FUGU_EXEC_BIN", FUGU_LIBEXEC "/fugu-exec" },
+ { "fetch", EP_FETCH, "FUGU_FETCH_BIN", FUGU_LIBEXEC "/fugu-fetch" },
+};
+
+struct worker {
+ pid_t pid;
+ struct imsgproto ip;
+ int active;
+};
+
+static struct worker workers[NWORKERS];
+
+/* One row of `fugu -l` output (session id, mtime, message count, preview). */
+struct sessrow {
+ char id[64];
+ time_t mtime;
+ int nmsg;
+ char preview[56];
+};
+
+static __dead void usage(void);
+static void create_session_dir(void);
+static int sessions_path(char *, size_t);
+static int session_exists(const char *);
+static int session_select(const struct dirent *);
+static int resolve_last(char *, size_t);
+static void session_preview(const char *, size_t, char *, size_t);
+static void scan_session(const char *, struct sessrow *);
+static int sessrow_cmp(const void *, const void *);
+static int list_sessions(void);
+static int run(struct conf_settings *, struct conf_providers *,
+ const char *, int);
+static int spawn(int, char *const[], int);
+static const char *trusted_env(const char *);
+static void delegate_net(struct conf_settings *,
+ struct conf_providers *);
+static void delegate_exec(struct conf_settings *);
+static void delegate_fetch(struct conf_settings *);
+static int secret_load(int, const char *, int, const char *,
+ char **, size_t *);
+static char *resolve_secret(int, const char *, int, const char *);
+static struct worker *worker_by_ep(uint32_t);
+static int relay_loop(void);
+static int relay_drain(struct worker *);
+static void shutdown_workers(void);
+
+static void
+usage(void)
+{
+ extern char *__progname;
+
+ fprintf(stderr, "usage: %s [-cdlnv] [-r id]\n", __progname);
+ exit(1);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct conf_settings eff;
+ struct conf_providers provs;
+ const char *rid = NULL, *resume_id = NULL;
+ char last[64];
+ int ch, debug = 0, verbose = 0, confcheck = 0;
+ int lflag = 0, cflag = 0, rc;
+
+ while ((ch = getopt(argc, argv, "cdlnr:v")) != -1) {
+ switch (ch) {
+ case 'c': /* resume the most recent session */
+ cflag = 1;
+ break;
+ case 'd':
+ debug = 1;
+ break;
+ case 'l': /* list sessions and exit */
+ lflag = 1;
+ break;
+ case 'n':
+ confcheck = 1;
+ break;
+ case 'r': /* resume the named session */
+ rid = optarg;
+ break;
+ case 'v':
+ verbose++;
+ break;
+ default:
+ usage();
+ }
+ }
+ argc -= optind;
+ argv += optind;
+ if (argc > 0 || (cflag && rid != NULL))
+ usage();
+
+ /*
+ * Startup runs on the terminal, before the ui takes the screen, so log
+ * to stderr: config-parse and other startup errors must be visible to
+ * whoever ran fugu, not buried in syslog. run() switches to syslog once
+ * the ui owns the terminal (unless -d keeps everything on stderr).
+ */
+ log_init(LOG_TO_STDERR, verbose, LOG_USER);
+
+ memset(&eff, 0, sizeof(eff));
+ TAILQ_INIT(&provs);
+ if (conf_load(&eff, &provs) == -1)
+ return (1);
+
+ if (confcheck) {
+ conf_settings_print(&eff, stdout);
+ conf_providers_print(&provs, stdout);
+ conf_settings_clear(&eff);
+ conf_providers_clear(&provs);
+ return (0);
+ }
+ if (lflag) {
+ rc = list_sessions();
+ conf_settings_clear(&eff);
+ conf_providers_clear(&provs);
+ return (rc);
+ }
+
+ if (rid != NULL) {
+ if (session_exists(rid) == -1) {
+ log_warnx("no such session: %s", rid);
+ conf_settings_clear(&eff);
+ conf_providers_clear(&provs);
+ return (1);
+ }
+ resume_id = rid;
+ } else if (cflag) {
+ if (resolve_last(last, sizeof(last)) == -1) {
+ log_warnx("no sessions to resume");
+ conf_settings_clear(&eff);
+ conf_providers_clear(&provs);
+ return (1);
+ }
+ resume_id = last;
+ }
+
+ create_session_dir();
+ signal(SIGPIPE, SIG_IGN);
+ rc = run(&eff, &provs, resume_id, debug);
+ conf_settings_clear(&eff);
+ conf_providers_clear(&provs);
+ return (rc);
+}
+
+/* Ensure ~/.fugu/sessions exists so the ui can write its journal (best-effort). */
+static void
+create_session_dir(void)
+{
+ const char *home;
+ char path[PATH_MAX];
+
+ if ((home = getenv("HOME")) == NULL || *home == '\0')
+ return;
+ if (snprintf(path, sizeof(path), "%s/.fugu", home) < (int)sizeof(path))
+ (void)mkdir(path, 0700);
+ if (snprintf(path, sizeof(path), "%s/.fugu/sessions", home) <
+ (int)sizeof(path))
+ (void)mkdir(path, 0700);
+}
+
+/* Build "$HOME/.fugu/sessions" into buf. -1 if HOME is unset or it won't fit. */
+static int
+sessions_path(char *buf, size_t bufsz)
+{
+ const char *home;
+
+ if ((home = getenv("HOME")) == NULL || *home == '\0')
+ return (-1);
+ if (snprintf(buf, bufsz, "%s/.fugu/sessions", home) >= (int)bufsz)
+ return (-1);
+ return (0);
+}
+
+/* 0 if session <id> names a readable journal file, -1 otherwise. */
+static int
+session_exists(const char *id)
+{
+ char dir[PATH_MAX], path[PATH_MAX];
+ struct stat st;
+
+ if (strchr(id, '/') != NULL || strcmp(id, ".") == 0 ||
+ strcmp(id, "..") == 0)
+ return (-1);
+ if (sessions_path(dir, sizeof(dir)) == -1)
+ return (-1);
+ if (snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id) >=
+ (int)sizeof(path))
+ return (-1);
+ if (stat(path, &st) == -1 || !S_ISREG(st.st_mode))
+ return (-1);
+ return (0);
+}
+
+/* scandir(3) filter: accept "<id>.jsonl" session journals. */
+static int
+session_select(const struct dirent *e)
+{
+ size_t nlen = strlen(e->d_name);
+
+ return (nlen > 6 && strcmp(e->d_name + nlen - 6, ".jsonl") == 0);
+}
+
+/* Find the most recently modified session id (by mtime). 0 ok, -1 if none. */
+static int
+resolve_last(char *out, size_t outsz)
+{
+ char dir[PATH_MAX], path[PATH_MAX];
+ struct dirent **names;
+ struct stat st;
+ time_t best = 0;
+ size_t nlen;
+ int n, i, found = 0;
+
+ if (sessions_path(dir, sizeof(dir)) == -1)
+ return (-1);
+ if ((n = scandir(dir, &names, session_select, NULL)) == -1)
+ return (-1);
+ for (i = 0; i < n; i++) {
+ nlen = strlen(names[i]->d_name);
+ if (nlen - 6 < outsz &&
+ snprintf(path, sizeof(path), "%s/%s", dir, names[i]->d_name) <
+ (int)sizeof(path) &&
+ stat(path, &st) != -1 && S_ISREG(st.st_mode) &&
+ (!found || st.st_mtime > best)) {
+ memcpy(out, names[i]->d_name, nlen - 6); /* strip ".jsonl" */
+ out[nlen - 6] = '\0';
+ best = st.st_mtime;
+ found = 1;
+ }
+ free(names[i]);
+ }
+ free(names);
+ return (found ? 0 : -1);
+}
+
+/* Extract a one-line, control-free preview from a stored message object. */
+static void
+session_preview(const char *json, size_t len, char *out, size_t outsz)
+{
+ struct json_doc d;
+ char *s = NULL;
+ size_t i, o = 0;
+ int ct;
+
+ out[0] = '\0';
+ if (json_parse(&d, json, len) != 0 || d.ntok < 1) {
+ json_doc_free(&d);
+ return;
+ }
+ if ((ct = json_obj_get(&d, 0, "content")) >= 0 &&
+ json_tok_type(&d, ct) == JSON_STRING)
+ s = json_tok_strdup(&d, ct);
+ json_doc_free(&d);
+ if (s == NULL)
+ return;
+
+ for (i = 0; s[i] != '\0' && o + 1 < outsz; i++) {
+ unsigned char c = (unsigned char)s[i];
+
+ if (c < 0x20 || c == 0x7f)
+ c = ' '; /* fold control chars to a space */
+ if (c == ' ' && (o == 0 || out[o - 1] == ' '))
+ continue; /* collapse runs of whitespace */
+ out[o++] = (char)c;
+ }
+ while (o > 0 && out[o - 1] == ' ')
+ o--;
+ out[o] = '\0';
+ free(s);
+}
+
+/* Count the messages in a session file and read a preview from the first. */
+static void
+scan_session(const char *path, struct sessrow *r)
+{
+ FILE *fp;
+ char *line = NULL;
+ size_t cap = 0;
+ ssize_t n;
+ int first = 1;
+
+ r->nmsg = 0;
+ r->preview[0] = '\0';
+ if ((fp = fopen(path, "r")) == NULL)
+ return;
+ while ((n = getline(&line, &cap, fp)) != -1) {
+ while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+ n--;
+ if (n == 0)
+ continue;
+ r->nmsg++;
+ if (first) {
+ first = 0;
+ session_preview(line, (size_t)n, r->preview,
+ sizeof(r->preview));
+ }
+ }
+ free(line);
+ fclose(fp);
+}
+
+/* qsort comparator: most recently modified session first. */
+static int
+sessrow_cmp(const void *a, const void *b)
+{
+ const struct sessrow *x = a, *y = b;
+
+ if (x->mtime < y->mtime)
+ return (1);
+ if (x->mtime > y->mtime)
+ return (-1);
+ return (0);
+}
+
+/* `fugu -l`: print the saved sessions, newest first. Returns an exit code. */
+static int
+list_sessions(void)
+{
+ char dir[PATH_MAX], path[PATH_MAX], when[32];
+ struct dirent **names;
+ struct stat st;
+ struct tm tm;
+ struct sessrow *rows = NULL;
+ size_t nrows = 0, i, nlen;
+ int n, j;
+
+ if (sessions_path(dir, sizeof(dir)) == -1) {
+ log_warnx("HOME is not set");
+ return (1);
+ }
+ if ((n = scandir(dir, &names, session_select, NULL)) == -1) {
+ printf("no saved sessions\n");
+ return (0);
+ }
+ for (j = 0; j < n; j++) {
+ struct sessrow r;
+
+ nlen = strlen(names[j]->d_name);
+ if (nlen - 6 < sizeof(r.id) &&
+ snprintf(path, sizeof(path), "%s/%s", dir, names[j]->d_name) <
+ (int)sizeof(path) &&
+ stat(path, &st) != -1 && S_ISREG(st.st_mode)) {
+ memset(&r, 0, sizeof(r));
+ memcpy(r.id, names[j]->d_name, nlen - 6);
+ r.id[nlen - 6] = '\0';
+ r.mtime = st.st_mtime;
+ scan_session(path, &r);
+ rows = xreallocarray(rows, nrows + 1, sizeof(*rows));
+ rows[nrows++] = r;
+ }
+ free(names[j]);
+ }
+ free(names);
+
+ if (nrows == 0) {
+ printf("no saved sessions\n");
+ free(rows);
+ return (0);
+ }
+ qsort(rows, nrows, sizeof(*rows), sessrow_cmp);
+ printf("%-22s %-16s %4s %s\n", "SESSION", "MODIFIED", "MSGS",
+ "FIRST MESSAGE");
+ for (i = 0; i < nrows; i++) {
+ when[0] = '\0';
+ if (localtime_r(&rows[i].mtime, &tm) != NULL)
+ strftime(when, sizeof(when), "%Y-%m-%d %H:%M", &tm);
+ printf("%-22s %-16s %4d %s\n", rows[i].id, when,
+ rows[i].nmsg, rows[i].preview);
+ }
+ free(rows);
+ return (0);
+}
+
+static int
+run(struct conf_settings *eff, struct conf_providers *provs,
+ const char *resume_id, int debug)
+{
+ int subproc_net, ascii, i;
+
+ subproc_net = (eff->set & CS_ALLOW_SUBPROC_NET) &&
+ eff->allow_subprocess_net;
+ ascii = (eff->set & CS_ASCII) && eff->ascii;
+
+ for (i = 0; i < NWORKERS; i++) {
+ /*
+ * Some startup policy travels as argv flags rather than over the
+ * imsg config channel: exec's subprocess-network (fixed before the
+ * worker pledges), and the ui's ascii display + resume session id.
+ */
+ char *extra[4];
+ int nextra = 0;
+
+ if (i == W_EXEC && subproc_net)
+ extra[nextra++] = "-N";
+ else if (i == W_UI) {
+ if (ascii)
+ extra[nextra++] = "-a";
+ if (resume_id != NULL) {
+ extra[nextra++] = "-R";
+ extra[nextra++] = (char *)(uintptr_t)resume_id;
+ }
+ }
+
+ if (spawn(i, extra, nextra) == -1) {
+ log_warnx("failed to spawn %s", WDESC[i].name);
+ shutdown_workers();
+ return (1);
+ }
+ }
+
+ /* Any key file is read here, before we drop rpath. */
+ delegate_net(eff, provs);
+ delegate_exec(eff);
+ delegate_fetch(eff);
+
+ /*
+ * The secrets are read and delegated; shed the setgid _fugu group (the
+ * workers already dropped their inherited copy at startup) so nothing
+ * downstream can reach /etc/fugu.conf or other _fugu-readable files.
+ */
+ drop_privgroup();
+
+ if (pledge("stdio proc", NULL) == -1)
+ fatal("pledge");
+
+ /*
+ * The ui now owns the terminal; route the parent's own diagnostics to
+ * syslog so they don't paint over the curses display. -d keeps them on
+ * stderr for debugging (the screen will be garbled, but that is the
+ * debugging tradeoff).
+ */
+ if (!debug)
+ log_init(LOG_TO_SYSLOG, log_getverbose(), LOG_USER);
+
+ relay_loop();
+ shutdown_workers();
+ return (0);
+}
+
+/*
+ * getenv() that an installed (set-group-id) fugu ignores: when issetugid() is
+ * true the environment is untrusted, so a privileged process must not let it
+ * steer worker binary paths, endpoints, or the CA bundle. Honoured only in a
+ * non-setgid binary -- development and the regress harness.
+ */
+static const char *
+trusted_env(const char *name)
+{
+ return (issetugid() ? NULL : getenv(name));
+}
+
+/* Fork+exec worker i (with any extra argv words), on fd 3. */
+static int
+spawn(int i, char *const extra[], int nextra)
+{
+ const char *bin;
+ pid_t pid;
+ int sv[2];
+
+ bin = trusted_env(WDESC[i].env_bin);
+ if (bin == NULL || *bin == '\0')
+ bin = WDESC[i].def_bin;
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1) {
+ log_warn("socketpair");
+ return (-1);
+ }
+ if ((pid = fork()) == -1) {
+ log_warn("fork");
+ close(sv[0]);
+ close(sv[1]);
+ return (-1);
+ }
+ if (pid == 0) {
+ char *av[8]; /* bin + at most a few flags + NULL */
+ int n = 0, k;
+
+ av[n++] = (char *)(uintptr_t)bin;
+ for (k = 0; k < nextra && n < 7; k++)
+ av[n++] = extra[k];
+ av[n] = NULL;
+
+ if (dup2(sv[1], IMSG_CHILD_FD) == -1)
+ _exit(127);
+ /*
+ * Only the ui owns the terminal; the other workers have no use
+ * for stdin/stdout, so point them at /dev/null (stderr is kept
+ * for debug logging). They communicate solely over fd 3.
+ */
+ if (i != W_UI) {
+ int nfd = open("/dev/null", O_RDWR);
+
+ if (nfd != -1) {
+ (void)dup2(nfd, STDIN_FILENO);
+ (void)dup2(nfd, STDOUT_FILENO);
+ if (nfd > IMSG_CHILD_FD)
+ close(nfd);
+ }
+ }
+ closefrom(IMSG_CHILD_FD + 1); /* drop every other channel */
+ execv(bin, av);
+ _exit(127);
+ }
+
+ close(sv[1]);
+ if (ip_init(&workers[i].ip, sv[0]) == -1) {
+ log_warn("ip_init");
+ return (-1);
+ }
+ workers[i].pid = pid;
+ workers[i].active = 1;
+ return (0);
+}
+
+/*
+ * One model provider to delegate. Its non-secret fields come from a
+ * conf_settings (the flat "default" provider, or a named provider block); host
+ * and port are resolved separately so an env override can pin the default's
+ * endpoint for tests. The secret is resolved at send time from `s` so it never
+ * lingers in this descriptor.
+ */
+struct netprov_desc {
+ const char *name;
+ const struct conf_settings *s;
+ const char *host; /* or NULL for the type default */
+ const char *port; /* or NULL for 443 */
+};
+
+/*
+ * Resolve one descriptor's API key with its true byte length: generic
+ * api_key first, then legacy. Returns 0 on success (*out / *outlen filled),
+ * -1 if no key is configured.
+ */
+static int
+netprov_key(const struct conf_settings *s, char **out, size_t *outlen)
+{
+ if (secret_load(s->set & CS_API_KEY, s->api_key,
+ s->set & CS_API_KEY_FILE, s->api_key_file, out, outlen) == 0)
+ return (0);
+ return (secret_load(s->set & CS_ANTHROPIC_KEY, s->anthropic_key,
+ s->set & CS_ANTHROPIC_KEY_FILE, s->anthropic_key_file,
+ out, outlen));
+}
+
+static void
+delegate_net(struct conf_settings *eff, struct conf_providers *provs)
+{
+ struct worker *net = &workers[W_NET];
+ struct json_writer w;
+ struct buf cfg;
+ struct conf_provider *cp;
+ struct netprov_desc *descs;
+ const char *cafile, *envhost, *envport;
+ size_t ndesc = 0, cap, i;
+ int include_default;
+
+ /*
+ * The flat top-level/match keys define an implicit "default" provider --
+ * included whenever any model-API key or endpoint was set there, and also
+ * when no named providers exist (so there is always at least one provider,
+ * which yields the friendly "no api_key" error if it too is keyless).
+ */
+ include_default = (eff->set & (CS_API_KEY | CS_API_KEY_FILE |
+ CS_ANTHROPIC_KEY | CS_ANTHROPIC_KEY_FILE | CS_PROVIDER | CS_API_HOST |
+ CS_API_PATH)) != 0 || TAILQ_EMPTY(provs);
+
+ cap = 1;
+ TAILQ_FOREACH(cp, provs, entry)
+ cap++;
+ descs = xreallocarray(NULL, cap, sizeof(*descs));
+
+ envhost = trusted_env("FUGU_NET_HOST");
+ envport = trusted_env("FUGU_NET_PORT");
+ if (include_default) {
+ descs[ndesc].name = "default";
+ descs[ndesc].s = eff;
+ descs[ndesc].host = envhost != NULL ? envhost :
+ (eff->set & CS_API_HOST ? eff->api_host : NULL);
+ descs[ndesc].port = envport != NULL ? envport :
+ (eff->set & CS_API_PORT ? eff->api_port : NULL);
+ ndesc++;
+ }
+ TAILQ_FOREACH(cp, provs, entry) {
+ descs[ndesc].name = cp->name;
+ descs[ndesc].s = &cp->settings;
+ descs[ndesc].host = cp->settings.set & CS_API_HOST ?
+ cp->settings.api_host : NULL;
+ descs[ndesc].port = cp->settings.set & CS_API_PORT ?
+ cp->settings.api_port : NULL;
+ ndesc++;
+ }
+
+ /* M_CONFIG: the non-secret provider array plus shared turn settings. */
+ buf_init(&cfg);
+ json_writer_init(&w, &cfg);
+ json_object_start(&w);
+ if (eff->set & CS_SYSTEM)
+ json_kv_string(&w, "system", eff->system);
+ if (eff->set & CS_MAX_TOKENS)
+ json_kv_int(&w, "max_tokens", eff->max_tokens);
+ /* Web tools are offered unless explicitly disabled. */
+ json_kv_bool(&w, "web_search",
+ (eff->set & CS_WEB_SEARCH) ? eff->web_search : 1);
+ /* The general http_request tool is offered only when an allowlist exists. */
+ json_kv_bool(&w, "http_request", (eff->set & CS_HTTP_ALLOW) &&
+ eff->http_allow != NULL && eff->http_allow[0] != '\0');
+ if ((cafile = trusted_env("FUGU_NET_CAFILE")) != NULL)
+ json_kv_string(&w, "cafile", cafile);
+ json_kv_int(&w, "active", 0); /* the default/first provider starts active */
+ json_key(&w, "providers");
+ json_array_start(&w);
+ for (i = 0; i < ndesc; i++) {
+ const struct conf_settings *s = descs[i].s;
+
+ json_object_start(&w);
+ json_kv_string(&w, "name", descs[i].name);
+ /* Wire protocol: "openai" selects chat/completions, else anthropic. */
+ json_kv_string(&w, "type",
+ (s->set & CS_PROVIDER) ? s->provider : "anthropic");
+ if (s->set & CS_MODEL)
+ json_kv_string(&w, "model", s->model);
+ if (descs[i].host != NULL)
+ json_kv_string(&w, "host", descs[i].host);
+ if (descs[i].port != NULL)
+ json_kv_string(&w, "port", descs[i].port);
+ if (s->set & CS_API_PATH)
+ json_kv_string(&w, "path", s->api_path);
+ json_object_end(&w);
+ }
+ json_array_end(&w);
+ json_object_end(&w);
+
+ if (ip_compose(&net->ip, M_CONFIG, EP_NET, EP_PARENT, cfg.data,
+ cfg.len) == -1 || ip_flush(&net->ip) == -1)
+ log_warnx("net: config delegation failed");
+ buf_free(&cfg);
+
+ /*
+ * One M_SECRET per provider that resolves a key. The payload is the
+ * provider's index (uint32, host order) followed by the key bytes, so the
+ * key still travels only in M_SECRET -- never in argv, env, or M_CONFIG.
+ */
+ for (i = 0; i < ndesc; i++) {
+ struct buf sec;
+ uint32_t idx = (uint32_t)i;
+ char *key;
+ size_t keylen;
+
+ if (netprov_key(descs[i].s, &key, &keylen) == -1) {
+ if (ndesc == 1)
+ log_warnx("no api_key/anthropic_key configured; "
+ "net cannot authenticate");
+ continue;
+ }
+ buf_init(&sec);
+ buf_append(&sec, &idx, sizeof(idx));
+ buf_append(&sec, key, keylen);
+ if (ip_compose(&net->ip, M_SECRET, EP_NET, EP_PARENT, sec.data,
+ sec.len) == -1 || ip_flush(&net->ip) == -1)
+ log_warnx("net: secret delegation failed");
+ buf_freezero(&sec); /* held the key bytes */
+ freezero(key, keylen);
+ }
+ free(descs);
+}
+
+static void
+delegate_exec(struct conf_settings *eff)
+{
+ struct worker *exec = &workers[W_EXEC];
+ struct json_writer w;
+ struct buf cfg;
+ int allow;
+
+ allow = (eff->set & CS_ALLOW_WRITE) ? eff->allow_write : 1;
+
+ buf_init(&cfg);
+ json_writer_init(&w, &cfg);
+ json_object_start(&w);
+ json_kv_bool(&w, "allow_write", allow);
+ json_object_end(&w);
+ if (ip_compose(&exec->ip, M_CONFIG, EP_EXEC, EP_PARENT, cfg.data,
+ cfg.len) == -1 || ip_flush(&exec->ip) == -1)
+ log_warnx("exec: config delegation failed");
+ buf_free(&cfg);
+}
+
+static void
+delegate_fetch(struct conf_settings *eff)
+{
+ struct worker *fetch = &workers[W_FETCH];
+ struct json_writer w;
+ struct buf cfg;
+ const char *host, *port, *cafile;
+ char *token;
+
+ buf_init(&cfg);
+ json_writer_init(&w, &cfg);
+ json_object_start(&w);
+ /* Endpoint override (test/advanced); production uses kagi.com:443. */
+ if ((host = trusted_env("FUGU_FETCH_KAGI_HOST")) != NULL)
+ json_kv_string(&w, "kagi_host", host);
+ if ((port = trusted_env("FUGU_FETCH_KAGI_PORT")) != NULL)
+ json_kv_string(&w, "kagi_port", port);
+ if ((cafile = trusted_env("FUGU_FETCH_CAFILE")) != NULL)
+ json_kv_string(&w, "cafile", cafile);
+ /* Opt out of the web_fetch SSRF guard (e.g. to reach an intranet). */
+ if (trusted_env("FUGU_FETCH_ALLOW_PRIVATE") != NULL)
+ json_kv_bool(&w, "allow_private", 1);
+ /* Host allow/block lists gating the model's http_request tool. */
+ if (eff->set & CS_HTTP_ALLOW)
+ json_kv_string(&w, "http_allow", eff->http_allow);
+ if (eff->set & CS_HTTP_BLOCK)
+ json_kv_string(&w, "http_block", eff->http_block);
+ json_object_end(&w);
+
+ if (ip_compose(&fetch->ip, M_CONFIG, EP_FETCH, EP_PARENT, cfg.data,
+ cfg.len) == -1 || ip_flush(&fetch->ip) == -1)
+ log_warnx("fetch: config delegation failed");
+ buf_free(&cfg);
+
+ /* The Kagi token is optional: without it, web_fetch still works. */
+ {
+ size_t tokenlen;
+
+ if (secret_load(eff->set & CS_KAGI_TOKEN, eff->kagi_token,
+ eff->set & CS_KAGI_TOKEN_FILE, eff->kagi_token_file,
+ &token, &tokenlen) == 0) {
+ if (ip_compose(&fetch->ip, M_SECRET, EP_FETCH,
+ EP_PARENT, token, tokenlen) == -1 ||
+ ip_flush(&fetch->ip) == -1)
+ log_warnx("fetch: secret delegation failed");
+ freezero(token, tokenlen);
+ }
+ }
+}
+
+/*
+ * Length-aware secret loader. Produces (*out, *outlen) on success and 0
+ * return; on failure or empty secret returns -1 with *out unchanged. The
+ * length is the true byte count, NOT strlen(*out) -- so a file-loaded secret
+ * that legitimately contains embedded NULs survives the round trip into the
+ * M_SECRET payload and freezero() clears every byte, not just up to the
+ * first NUL. Inline secrets (config STRING tokens) cannot contain NULs by
+ * construction of the conf parser, but reporting their length too keeps the
+ * single API.
+ */
+static int
+secret_load(int have_inline, const char *inline_val, int have_file,
+ const char *file_path, char **out, size_t *outlen)
+{
+ FILE *fp;
+ char *line = NULL;
+ size_t cap = 0;
+ ssize_t n;
+
+ if (have_inline) {
+ size_t len;
+
+ if (inline_val == NULL || *inline_val == '\0') {
+ log_warnx("empty inline secret");
+ return (-1);
+ }
+ len = strlen(inline_val);
+ *out = xstrdup(inline_val);
+ *outlen = len;
+ return (0);
+ }
+ if (!have_file)
+ return (-1);
+
+ if ((fp = fopen(file_path, "r")) == NULL) {
+ log_warn("open %s", file_path);
+ return (-1);
+ }
+ /* A key file holds a secret: enforce StrictModes on the open fd. */
+ if (conf_check_secrecy(fileno(fp), file_path, getuid()) == -1) {
+ fclose(fp);
+ return (-1);
+ }
+ n = getline(&line, &cap, fp);
+ fclose(fp);
+ if (n <= 0) {
+ free(line);
+ log_warnx("empty key file: %s", file_path);
+ return (-1);
+ }
+ while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+ line[--n] = '\0';
+ if (n == 0) {
+ free(line);
+ log_warnx("empty key file: %s", file_path);
+ return (-1);
+ }
+ *out = line;
+ *outlen = (size_t)n;
+ return (0);
+}
+
+/*
+ * Back-compat wrapper that throws away the length. Use only when the caller
+ * is about to feed the bytes into a strlen-safe sink (e.g. a log message).
+ * Secret-bearing paths must call secret_load() directly so freezero() and
+ * the imsg payload see the true byte count.
+ */
+static char *
+resolve_secret(int have_inline, const char *inline_val, int have_file,
+ const char *file_path)
+{
+ char *out;
+ size_t len;
+
+ (void)len;
+ if (secret_load(have_inline, inline_val, have_file, file_path,
+ &out, &len) == -1)
+ return (NULL);
+ return (out);
+}
+
+static struct worker *
+worker_by_ep(uint32_t ep)
+{
+ int i;
+
+ for (i = 0; i < NWORKERS; i++) {
+ if (WDESC[i].ep == ep && workers[i].active)
+ return (&workers[i]);
+ }
+ return (NULL);
+}
+
+static int
+relay_loop(void)
+{
+ struct pollfd pfd[NWORKERS];
+ int i, n;
+
+ for (;;) {
+ n = 0;
+ for (i = 0; i < NWORKERS; i++) {
+ if (!workers[i].active)
+ continue;
+ pfd[i].fd = ip_fd(&workers[i].ip);
+ pfd[i].events = POLLIN;
+ pfd[i].revents = 0;
+ n++;
+ }
+ if (n == 0)
+ return (0);
+
+ if (poll(pfd, NWORKERS, -1) == -1) {
+ if (errno == EINTR)
+ continue;
+ log_warn("poll");
+ return (-1);
+ }
+ for (i = 0; i < NWORKERS; i++) {
+ if (!workers[i].active)
+ continue;
+ if (pfd[i].revents & (POLLHUP | POLLERR | POLLNVAL)) {
+ log_info("%s exited", WDESC[i].name);
+ return (0);
+ }
+ if (pfd[i].revents & POLLIN) {
+ if (relay_drain(&workers[i]) == -1)
+ return (0);
+ }
+ }
+ }
+}
+
+static int
+relay_drain(struct worker *from)
+{
+ struct worker *to;
+ struct imsg imsg;
+ uint32_t dst;
+ int n;
+
+ if (ip_read(&from->ip) <= 0)
+ return (-1); /* worker closed the channel */
+
+ for (;;) {
+ n = ip_frame_get(&from->ip, &imsg);
+ if (n == -1)
+ return (-1);
+ if (n == 0)
+ return (0);
+
+ dst = imsg_get_id(&imsg);
+ if (dst == EP_PARENT) {
+ /* worker -> parent control frame (e.g. M_READY) */
+ imsg_free(&imsg);
+ continue;
+ }
+ if ((to = worker_by_ep(dst)) != NULL) {
+ if (ip_forward(&to->ip, &imsg) == -1 ||
+ ip_flush(&to->ip) == -1)
+ log_warnx("relay to %u failed", dst);
+ } else
+ log_warnx("relay: no worker for dst %u", dst);
+ imsg_free(&imsg);
+ }
+}
+
+static void
+shutdown_workers(void)
+{
+ int i, status;
+
+ for (i = 0; i < NWORKERS; i++) {
+ if (!workers[i].active)
+ continue;
+ close(ip_fd(&workers[i].ip)); /* EOF -> worker exits */
+ ip_clear(&workers[i].ip);
+ workers[i].active = 0;
+ }
+ for (i = 0; i < NWORKERS; i++) {
+ if (workers[i].pid > 0)
+ waitpid(workers[i].pid, &status, 0);
+ }
+}
blob - /dev/null
blob + 11caed2326b502325808956e45d0ebc55fc99aee (mode 644)
--- /dev/null
+++ src/ui/Makefile
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG= fugu-ui
+SRCS+= ui.c ui_curses.c journal.c convo.c skills.c mdrender.c fuzzy.c
+BINDIR= ${PREFIX}/libexec/fugu
+LDADD+= -lcurses
+DPADD+= ${LIBCURSES}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + c947869bc06b803685cc68581be9566285cf557b (mode 644)
--- /dev/null
+++ src/ui/convo.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The conversation model shared by both front-ends: an ordered list of complete
+ * message objects (raw JSON), the projection rebuilt from it for each M_SUBMIT,
+ * and the resume path that replays a session journal back into a fresh convo and
+ * (via a callback) into whatever the front-end uses to display a transcript.
+ *
+ * Keeping this out of ui.c (which owns main()) lets it be unit-tested directly.
+ */
+
+#include <sys/types.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "journal.h"
+#include "json.h"
+#include "ui.h"
+#include "util.h"
+
+/* Append a {"role":<role>,"content":<text>} message built from plain text. */
+static void
+convo_add_role(struct convo *cv, const char *role, const char *text)
+{
+ struct json_writer w;
+ struct buf b;
+
+ buf_init(&b);
+ json_writer_init(&w, &b);
+ json_object_start(&w);
+ json_kv_string(&w, "role", role);
+ json_kv_string(&w, "content", text);
+ json_object_end(&w);
+ buf_terminate(&b);
+
+ cv->msg = xreallocarray(cv->msg, cv->n + 1, sizeof(*cv->msg));
+ cv->msg[cv->n++] = xstrdup(buf_cstr(&b));
+ buf_free(&b);
+}
+
+void
+convo_add_user(struct convo *cv, const char *text)
+{
+ convo_add_role(cv, "user", text);
+}
+
+void
+convo_clear(struct convo *cv)
+{
+ convo_truncate(cv, 0);
+}
+
+/*
+ * Replace the active context with a /compact summary. The summary is stored as
+ * a user/assistant pair so the next real user turn still alternates correctly --
+ * a lone synthetic message would leave two same-role turns adjacent, which the
+ * Messages API rejects.
+ */
+void
+convo_set_summary(struct convo *cv, const char *summary)
+{
+ convo_truncate(cv, 0);
+ convo_add_role(cv, "user", "Here is a summary of our conversation so "
+ "far; continue from where we left off.");
+ convo_add_role(cv, "assistant", summary);
+}
+
+/* Build the journal marker recording a /clear (context reset). */
+void
+convo_marker_clear(struct buf *out)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "fugu", "clear");
+ json_object_end(&w);
+}
+
+/* Build the journal marker recording a /compact, carrying the summary text. */
+void
+convo_marker_compact(struct buf *out, const char *summary)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "fugu", "compact");
+ json_kv_string(&w, "summary", summary);
+ json_object_end(&w);
+}
+
+/*
+ * Build the journal marker emitted when a turn fails mid-flight (HTTP error,
+ * channel drop, malformed stream). On resume it triggers a truncation back to
+ * the previous transactional boundary -- the most recent skill marker, plain
+ * user-typed message, /clear, /compact, or the start of the journal -- so the
+ * orphan records the failed turn wrote (synth pair, partial assistant blocks)
+ * never replay into the rebuilt convo.
+ */
+void
+convo_marker_turn_failed(struct buf *out)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "fugu", "turn_failed");
+ json_object_end(&w);
+}
+
+/*
+ * Append a turn_failed marker to the journal if (and only if) the failed turn
+ * wrote convo records past `mark`. Both UIs call this from every path that
+ * abandons an in-flight turn (M_ERROR, channel break, send failure): a single
+ * choke point keeps the protocol coherent and prevents two foot-guns:
+ *
+ * 1. Emitting when nothing was added (e.g. /compact failed before any state
+ * change). On resume the marker would roll back PAST the last real user
+ * turn -- the resumed convo silently loses earlier history.
+ * 2. Forgetting to emit on a quiet exit path. Orphan user/assistant records
+ * then replay into the rebuilt convo with no marker to discard them.
+ */
+void
+convo_journal_turn_failed(struct journal *j, const struct convo *cv,
+ size_t mark)
+{
+ struct buf mk;
+
+ if (cv->n <= mark)
+ return;
+ buf_init(&mk);
+ convo_marker_turn_failed(&mk);
+ journal_append(j, mk.data, mk.len);
+ buf_free(&mk);
+}
+
+void
+convo_add_raw(struct convo *cv, const char *json, size_t len)
+{
+ char *s = xmalloc(len + 1);
+
+ memcpy(s, json, len);
+ s[len] = '\0';
+ cv->msg = xreallocarray(cv->msg, cv->n + 1, sizeof(*cv->msg));
+ cv->msg[cv->n++] = s;
+}
+
+void
+convo_truncate(struct convo *cv, size_t n)
+{
+ while (cv->n > n)
+ free(cv->msg[--cv->n]);
+}
+
+void
+convo_free(struct convo *cv)
+{
+ convo_truncate(cv, 0);
+ free(cv->msg);
+ cv->msg = NULL;
+}
+
+void
+convo_build(const struct convo *cv, struct buf *out)
+{
+ size_t i;
+
+ buf_putc(out, '[');
+ for (i = 0; i < cv->n; i++) {
+ if (i > 0)
+ buf_putc(out, ',');
+ buf_puts_cstr(out, cv->msg[i]);
+ }
+ buf_putc(out, ']');
+}
+
+/* Hand one tool_use block to the front-end formatted like a live M_TOOL_CALL. */
+static void
+replay_tool_use(const struct json_doc *d, int obj, convo_part_cb cb, void *ctx)
+{
+ struct buf tb;
+ char *name = NULL, *input = NULL;
+ int nt, it;
+
+ if ((nt = json_obj_get(d, obj, "name")) >= 0)
+ name = json_tok_strdup(d, nt);
+ if ((it = json_obj_get(d, obj, "input")) >= 0)
+ input = json_tok_strdup(d, it);
+
+ buf_init(&tb);
+ buf_printf(&tb, "%s %s", name != NULL ? name : "(tool)",
+ input != NULL ? input : "");
+ cb(ctx, CP_TOOL, tb.data, tb.len);
+ buf_free(&tb);
+ free(name);
+ free(input);
+}
+
+void
+convo_replay(const char *json, size_t len, convo_part_cb cb, void *ctx)
+{
+ struct json_doc d;
+ int role_t, content_t, is_user, n, j, k;
+
+ if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ return;
+ }
+ role_t = json_obj_get(&d, 0, "role");
+ content_t = json_obj_get(&d, 0, "content");
+ if (role_t < 0 || content_t < 0) {
+ json_doc_free(&d);
+ return;
+ }
+ is_user = json_tok_streq(&d, role_t, "user");
+
+ /* A plain string is a typed user message (or a bare assistant reply). */
+ if (json_tok_type(&d, content_t) == JSON_STRING) {
+ char *s = json_tok_strdup(&d, content_t);
+
+ cb(ctx, is_user ? CP_USER : CP_ASSIST, s, strlen(s));
+ free(s);
+ json_doc_free(&d);
+ return;
+ }
+
+ /*
+ * Otherwise content is an array of blocks. Show what the live transcript
+ * shows: assistant text and tool_use calls. tool_result blocks (the user
+ * role's array form) carry skill bodies emitted by synth pairs -- the
+ * substantive part of a slash-skill invocation -- so they are drawn as
+ * CP_TOOL, the same channel as the matching tool_use.
+ */
+ if (json_tok_type(&d, content_t) != JSON_ARRAY) {
+ json_doc_free(&d);
+ return;
+ }
+ n = json_tok_size(&d, content_t);
+ j = content_t + 1;
+ for (k = 0; k < n; k++) {
+ int type_t;
+
+ if (json_tok_type(&d, j) == JSON_OBJECT &&
+ (type_t = json_obj_get(&d, j, "type")) >= 0) {
+ if (json_tok_streq(&d, type_t, "text")) {
+ int txt = json_obj_get(&d, j, "text");
+
+ if (txt >= 0) {
+ char *s = json_tok_strdup(&d, txt);
+
+ cb(ctx, CP_ASSIST, s, strlen(s));
+ free(s);
+ }
+ } else if (json_tok_streq(&d, type_t, "tool_use"))
+ replay_tool_use(&d, j, cb, ctx);
+ else if (json_tok_streq(&d, type_t, "tool_result")) {
+ int ct = json_obj_get(&d, j, "content");
+
+ if (ct >= 0 &&
+ json_tok_type(&d, ct) == JSON_STRING) {
+ char *s = json_tok_strdup(&d, ct);
+
+ cb(ctx, CP_TOOL, s, strlen(s));
+ free(s);
+ }
+ }
+ }
+ j = json_skip(&d, j);
+ }
+ json_doc_free(&d);
+}
+
+struct resume_ctx {
+ struct convo *cv;
+ convo_part_cb cb;
+ void *cbctx;
+ size_t boundary; /* convo length at last txn boundary */
+};
+
+/*
+ * A journal line is a conversation message or a marker. Markers travel a
+ * single text out-param: for RK_COMPACT it's the stored summary, for RK_SKILL
+ * it's the slash-name of the invoked skill.
+ */
+enum rec_kind {
+ RK_MESSAGE,
+ RK_CLEAR,
+ RK_COMPACT,
+ RK_SKILL,
+ RK_TURN_FAILED,
+ RK_SKIP,
+};
+
+static enum rec_kind
+record_kind(const char *json, size_t len, char **text)
+{
+ struct json_doc d;
+ enum rec_kind k = RK_MESSAGE;
+ int ft;
+
+ *text = NULL;
+ if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ return (RK_SKIP);
+ }
+ if ((ft = json_obj_get(&d, 0, "fugu")) >= 0) {
+ if (json_tok_streq(&d, ft, "clear"))
+ k = RK_CLEAR;
+ else if (json_tok_streq(&d, ft, "compact")) {
+ int st = json_obj_get(&d, 0, "summary");
+
+ k = RK_COMPACT;
+ if (st >= 0)
+ *text = json_tok_strdup(&d, st);
+ } else if (json_tok_streq(&d, ft, "skill")) {
+ int nt = json_obj_get(&d, 0, "name");
+
+ k = RK_SKILL;
+ if (nt >= 0)
+ *text = json_tok_strdup(&d, nt);
+ } else if (json_tok_streq(&d, ft, "turn_failed"))
+ k = RK_TURN_FAILED;
+ else
+ k = RK_SKIP;
+ }
+ json_doc_free(&d);
+ return (k);
+}
+
+/*
+ * Is `json` a plain typed user message (role=user, content=string)? Used to
+ * spot the start of a new transactional boundary on replay -- the rebuilt
+ * convo's `boundary` advances here, and `turn_failed` rolls back to it. A
+ * user-role tool_result block (content=array) is a continuation inside a turn
+ * and is intentionally NOT a boundary.
+ */
+static int
+is_user_typed_message(const char *json, size_t len)
+{
+ struct json_doc d;
+ int role_t, content_t, ok = 0;
+
+ if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT) {
+ json_doc_free(&d);
+ return (0);
+ }
+ role_t = json_obj_get(&d, 0, "role");
+ content_t = json_obj_get(&d, 0, "content");
+ ok = role_t >= 0 && content_t >= 0 &&
+ json_tok_streq(&d, role_t, "user") &&
+ json_tok_type(&d, content_t) == JSON_STRING;
+ json_doc_free(&d);
+ return (ok);
+}
+
+/*
+ * Replay one journal line: a message is kept and drawn; a /clear resets the
+ * active context (the prior messages stay on screen as scrollback, with a
+ * divider); a /compact replaces the context with its stored summary. A
+ * turn_failed marker rolls the convo back to the last transactional boundary
+ * (a skill marker, a typed user message, a /clear or /compact, or the start of
+ * the journal) so a turn that failed mid-flight does not leave orphan records
+ * in the replayed convo. This reconstructs exactly the active context that
+ * was in effect when resuming.
+ */
+static void
+resume_line(void *ctx, const char *line, size_t len)
+{
+ struct resume_ctx *rc = ctx;
+ char *text = NULL;
+
+ switch (record_kind(line, len, &text)) {
+ case RK_CLEAR:
+ convo_truncate(rc->cv, 0);
+ rc->boundary = 0;
+ if (rc->cb != NULL)
+ rc->cb(rc->cbctx, CP_INFO, "context cleared",
+ strlen("context cleared"));
+ break;
+ case RK_COMPACT:
+ convo_set_summary(rc->cv, text != NULL ? text : "");
+ rc->boundary = rc->cv->n;
+ if (rc->cb != NULL) {
+ rc->cb(rc->cbctx, CP_INFO,
+ "earlier conversation compacted",
+ strlen("earlier conversation compacted"));
+ if (text != NULL)
+ rc->cb(rc->cbctx, CP_ASSIST, text,
+ strlen(text));
+ }
+ break;
+ case RK_SKILL:
+ /*
+ * A skill marker opens a new transaction: the synth trio that
+ * follows is what turn_failed undoes. Pin the boundary here so
+ * the truncation drops the trio and any partial assistant
+ * blocks streamed before the failure.
+ */
+ rc->boundary = rc->cv->n;
+ if (rc->cb != NULL && text != NULL)
+ rc->cb(rc->cbctx, CP_INFO, text, strlen(text));
+ break;
+ case RK_TURN_FAILED:
+ convo_truncate(rc->cv, rc->boundary);
+ if (rc->cb != NULL)
+ rc->cb(rc->cbctx, CP_INFO,
+ "previous failed turn discarded",
+ strlen("previous failed turn discarded"));
+ break;
+ case RK_MESSAGE:
+ if (is_user_typed_message(line, len))
+ rc->boundary = rc->cv->n;
+ convo_add_raw(rc->cv, line, len);
+ if (rc->cb != NULL)
+ convo_replay(line, len, rc->cb, rc->cbctx);
+ break;
+ default: /* RK_SKIP: unrecognised line */
+ break;
+ }
+ free(text);
+}
+
+int
+convo_resume(struct convo *cv, const char *id, convo_part_cb cb, void *ctx)
+{
+ struct resume_ctx rc;
+
+ rc.cv = cv;
+ rc.cb = cb;
+ rc.cbctx = ctx;
+ rc.boundary = 0;
+ return (journal_foreach(id, resume_line, &rc));
+}
blob - /dev/null
blob + 6f86803733749d77cd79cd6eee565b373f76d17a (mode 644)
--- /dev/null
+++ src/ui/journal.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "journal.h"
+
+/* Build "$HOME/.fugu/sessions" into buf. -1 if HOME is unset or it won't fit. */
+static int
+sessions_dir(char *buf, size_t bufsz)
+{
+ const char *home;
+
+ if ((home = getenv("HOME")) == NULL || *home == '\0')
+ return (-1);
+ if (snprintf(buf, bufsz, "%s/.fugu/sessions", home) >= (int)bufsz)
+ return (-1);
+ return (0);
+}
+
+/* A resumed id is interpolated into a path; accept only a safe basename. */
+static int
+valid_id(const char *id)
+{
+ return (id != NULL && *id != '\0' && strchr(id, '/') == NULL &&
+ strcmp(id, ".") != 0 && strcmp(id, "..") != 0);
+}
+
+static int
+write_all(int fd, const char *p, size_t n)
+{
+ size_t off = 0;
+ ssize_t w;
+
+ while (off < n) {
+ w = write(fd, p + off, n - off);
+ if (w == -1) {
+ if (errno == EINTR)
+ continue;
+ return (-1);
+ }
+ if (w == 0) { /* should not happen on a regular file */
+ errno = EPIPE;
+ return (-1);
+ }
+ off += (size_t)w;
+ }
+ return (0);
+}
+
+void
+journal_unveil(void)
+{
+ const char *home;
+ char dir[PATH_MAX];
+
+ if ((home = getenv("HOME")) != NULL && *home != '\0' &&
+ snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home) <
+ (int)sizeof(dir))
+ (void)unveil(dir, "rwc"); /* missing dir -> journaling off */
+}
+
+void
+journal_open(struct journal *j, const char *id)
+{
+ char dir[PATH_MAX];
+
+ j->fd = -1;
+ j->id[0] = '\0';
+
+ if (sessions_dir(dir, sizeof(dir)) == -1)
+ return;
+
+ if (id != NULL && *id != '\0') {
+ /* Resuming: reuse the existing file so the record stays continuous. */
+ if (!valid_id(id) ||
+ strlcpy(j->id, id, sizeof(j->id)) >= sizeof(j->id)) {
+ j->id[0] = '\0';
+ return;
+ }
+ } else {
+ /* Epoch + pid: unique, sortable, and free of any timezone files. */
+ (void)snprintf(j->id, sizeof(j->id), "%lld-%ld",
+ (long long)time(NULL), (long)getpid());
+ }
+
+ if (snprintf(j->path, sizeof(j->path), "%s/%s.jsonl", dir, j->id) >=
+ (int)sizeof(j->path)) {
+ j->id[0] = '\0';
+ return;
+ }
+ j->fd = open(j->path, O_WRONLY | O_APPEND | O_CREAT, 0600);
+ /* On failure fd stays -1 and journaling is simply disabled. */
+}
+
+int
+journal_foreach(const char *id, void (*cb)(void *, const char *, size_t),
+ void *ctx)
+{
+ char dir[PATH_MAX], path[PATH_MAX];
+ FILE *fp;
+ char *line = NULL;
+ size_t cap = 0;
+ ssize_t n;
+
+ if (!valid_id(id) || sessions_dir(dir, sizeof(dir)) == -1)
+ return (-1);
+ if (snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id) >=
+ (int)sizeof(path))
+ return (-1);
+ if ((fp = fopen(path, "r")) == NULL)
+ return (-1);
+
+ while ((n = getline(&line, &cap, fp)) != -1) {
+ while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+ line[--n] = '\0';
+ if (n > 0)
+ cb(ctx, line, (size_t)n);
+ }
+ free(line);
+ fclose(fp);
+ return (0);
+}
+
+void
+journal_append(struct journal *j, const char *json, size_t len)
+{
+ if (j->fd < 0)
+ return;
+ if (write_all(j->fd, json, len) == -1 ||
+ write_all(j->fd, "\n", 1) == -1) {
+ close(j->fd);
+ j->fd = -1; /* stop after the first write error */
+ }
+}
+
+void
+journal_close(struct journal *j)
+{
+ if (j->fd >= 0) {
+ close(j->fd);
+ j->fd = -1;
+ }
+}
blob - /dev/null
blob + 8a28ae0cd532daa7c43a296bbaca4b7f1516ad20 (mode 644)
--- /dev/null
+++ src/ui/journal.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * journal: the append-only per-session record at ~/.fugu/sessions/<id>.jsonl.
+ * One JSON message object per line (the ksh-history / syslog idiom: written as
+ * it happens, never rewritten). Everything here is best-effort: if the file
+ * cannot be created or a write fails, journaling silently disables itself so a
+ * session is never interrupted by a logging problem. The fd is opened while the
+ * UI still has wpath/cpath and stays writable after the pledge narrows (writes
+ * to an already-open fd need only "stdio").
+ */
+
+#ifndef FUGU_JOURNAL_H
+#define FUGU_JOURNAL_H
+
+#include <sys/types.h>
+
+#include <limits.h>
+
+struct journal {
+ int fd; /* append fd, or -1 when disabled */
+ char id[64]; /* session id (also the file's basename) */
+ char path[PATH_MAX];
+};
+
+/* unveil(2) the sessions directory rwc (best-effort); call before locking. */
+void journal_unveil(void);
+
+/*
+ * Open (creating) ~/.fugu/sessions/<id>.jsonl for appending; id==NULL mints a
+ * fresh one. Never fails loudly: on any problem j->fd is left -1.
+ */
+void journal_open(struct journal *, const char *id);
+
+/* Append one JSON record as a line. No-op when disabled. */
+void journal_append(struct journal *, const char *json, size_t len);
+
+void journal_close(struct journal *);
+
+/*
+ * Read an existing session's journal, oldest line first, invoking cb for each
+ * non-empty line (trailing newline stripped). Used to resume a session.
+ * Returns 0 on success, -1 if the id is invalid or the file cannot be opened.
+ */
+int journal_foreach(const char *id,
+ void (*cb)(void *, const char *line, size_t len), void *ctx);
+
+#endif /* FUGU_JOURNAL_H */
blob - /dev/null
blob + 737fa8856f9f264c1a3d1ec129ca11520f96c027 (mode 644)
--- /dev/null
+++ src/ui/skills.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * skills loader. Discovers ~/.fugu/skills/<name>/SKILL.md files at startup,
+ * parses an optional YAML-ish header (only `name:` and `description:` are
+ * read), and keeps the body in memory for invocation. All file I/O is done up
+ * front so the runtime never needs rpath -- once skills_load() returns,
+ * fugu-ui can pledge straight down to "stdio" (or "stdio tty") without
+ * reopening anything.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "json.h"
+#include "log.h"
+#include "skills.h"
+#include "util.h"
+
+#define SKILLS_MAX 256 /* sanity cap on registry size */
+#define SKILL_MAX_BYTES (256 * 1024) /* per-file cap */
+
+/* Build "$HOME/.fugu/skills" into buf. -1 if HOME is unset or it won't fit. */
+static int
+skills_dir(char *buf, size_t bufsz)
+{
+ const char *home;
+
+ if ((home = getenv("HOME")) == NULL || *home == '\0')
+ return (-1);
+ if (snprintf(buf, bufsz, "%s/.fugu/skills", home) >= (int)bufsz)
+ return (-1);
+ return (0);
+}
+
+void
+skills_unveil(void)
+{
+ char dir[PATH_MAX];
+
+ if (skills_dir(dir, sizeof(dir)) == 0)
+ (void)unveil(dir, "r"); /* missing dir -> skills off */
+}
+
+/* Validate a directory entry name as a slash-command suffix. */
+static int
+valid_skill_name(const char *s)
+{
+ const char *p;
+
+ if (s == NULL || *s == '\0' || *s == '.')
+ return (0);
+ for (p = s; *p != '\0'; p++) {
+ if (!isalnum((unsigned char)*p) && *p != '-' && *p != '_')
+ return (0);
+ }
+ return (1);
+}
+
+/* Slurp the whole file at path, NUL-terminated, capped at SKILL_MAX_BYTES. */
+static char *
+slurp(const char *path, size_t *outlen)
+{
+ struct stat st;
+ int fd;
+ ssize_t r;
+ size_t off, sz;
+ char *buf;
+
+ if ((fd = open(path, O_RDONLY | O_CLOEXEC)) == -1)
+ return (NULL);
+ if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || st.st_size <= 0 ||
+ (size_t)st.st_size > SKILL_MAX_BYTES) {
+ close(fd);
+ return (NULL);
+ }
+ sz = (size_t)st.st_size;
+ buf = xmalloc(sz + 1);
+ off = 0;
+ while (off < sz) {
+ r = read(fd, buf + off, sz - off);
+ if (r == -1) {
+ if (errno == EINTR)
+ continue;
+ free(buf);
+ close(fd);
+ return (NULL);
+ }
+ if (r == 0)
+ break;
+ off += (size_t)r;
+ }
+ close(fd);
+ buf[off] = '\0';
+ if (outlen != NULL)
+ *outlen = off;
+ return (buf);
+}
+
+/* Trim ASCII whitespace from both ends in place. */
+static void
+trim(char *p)
+{
+ char *q;
+ size_t n, lead;
+
+ for (q = p; *q != '\0' && isspace((unsigned char)*q); q++)
+ ;
+ lead = (size_t)(q - p);
+ n = strlen(q);
+ if (lead > 0)
+ memmove(p, q, n + 1);
+ while (n > 0 && isspace((unsigned char)p[n - 1]))
+ p[--n] = '\0';
+}
+
+/*
+ * If text begins with "---" followed by a newline, scan for a matching closing
+ * fence; for each "key: value" line in between, copy the value into *name,
+ * *description, or *no_invoke (the last is set non-zero when the header carries
+ * `disable-model-invocation: true`). *body_off is set to the offset where the
+ * body starts (past the closing fence, or 0 if no header is present). Returns
+ * 0 on success (header parsed or no header), -1 if a fence opened but never
+ * closed -- the caller should treat that as a malformed file.
+ */
+static int
+parse_header(char *text, size_t len, char **name, char **description,
+ int *no_invoke, size_t *body_off)
+{
+ char *p, *end, *fence_end, *line, *nl, *colon, *key, *val;
+
+ *name = NULL;
+ *description = NULL;
+ *no_invoke = 0;
+ *body_off = 0;
+
+ if (len < 3 || strncmp(text, "---", 3) != 0)
+ return (0);
+ p = text + 3;
+ if (*p != '\n' && *p != '\r' && *p != '\0')
+ return (0); /* "---foo" -- not a header fence */
+ while (*p == '\r')
+ p++;
+ if (*p == '\n')
+ p++;
+
+ end = text + len;
+ fence_end = NULL;
+ for (line = p; line < end; line = nl + 1) {
+ nl = memchr(line, '\n', (size_t)(end - line));
+ if (nl == NULL)
+ nl = end;
+ if ((size_t)(nl - line) >= 3 && strncmp(line, "---", 3) == 0) {
+ fence_end = nl < end ? nl + 1 : end;
+ break;
+ }
+ if (nl == end)
+ break;
+ }
+ if (fence_end == NULL)
+ return (-1); /* unterminated header */
+ *body_off = (size_t)(fence_end - text);
+
+ /* Parse key:value lines between the two fences. */
+ for (line = p; line < end; line = nl + 1) {
+ nl = memchr(line, '\n', (size_t)(end - line));
+ if (nl == NULL)
+ nl = end;
+ if ((size_t)(nl - line) >= 3 && strncmp(line, "---", 3) == 0)
+ break;
+ if (nl < end)
+ *nl = '\0'; /* clobber: text is owned, not reread */
+ colon = strchr(line, ':');
+ if (colon != NULL) {
+ *colon = '\0';
+ key = line;
+ val = colon + 1;
+ trim(key);
+ trim(val);
+ if (*val != '\0') {
+ if (*name == NULL && strcmp(key, "name") == 0)
+ *name = xstrdup(val);
+ else if (*description == NULL &&
+ strcmp(key, "description") == 0)
+ *description = xstrdup(val);
+ else if (strcmp(key,
+ "disable-model-invocation") == 0 &&
+ (strcmp(val, "true") == 0 ||
+ strcmp(val, "yes") == 0 ||
+ strcmp(val, "1") == 0))
+ *no_invoke = 1;
+ }
+ }
+ if (nl == end)
+ break;
+ }
+ return (0);
+}
+
+/*
+ * Read one SKILL.md and append to sk. dirname is used as a fallback name if
+ * the header omits one. Silently returns on any malformed/unreadable file.
+ */
+static void
+load_one(struct skills *sk, const char *dirname, const char *path)
+{
+ struct skill s;
+ char *text, *hdr_name, *hdr_desc, *body;
+ size_t len, body_off;
+ int no_invoke = 0;
+
+ if ((text = slurp(path, &len)) == NULL)
+ return;
+
+ /*
+ * Reject any file containing an internal NUL byte BEFORE we parse. Both
+ * parse_header (xstrdup on the header `name:` / `description:` values)
+ * and the body copy below would strlen-truncate at the first NUL,
+ * silently dropping content downstream. Refuse the whole file here.
+ */
+ if (memchr(text, '\0', len) != NULL) {
+ log_warnx("skill %s: file contains NUL byte, skipped", dirname);
+ free(text);
+ return;
+ }
+
+ if (parse_header(text, len, &hdr_name, &hdr_desc, &no_invoke,
+ &body_off) == -1) {
+ free(text);
+ return; /* malformed frontmatter */
+ }
+
+ /* Body: everything after the closing fence; must be non-empty. */
+ body = xstrdup(text + body_off);
+ free(text);
+ trim(body);
+ if (*body == '\0') {
+ free(hdr_name);
+ free(hdr_desc);
+ free(body);
+ return;
+ }
+
+ /* Build the slash-form name: header `name:` wins over the dirname. */
+ s.description = hdr_desc;
+ s.body = body;
+ s.no_model_invoke = no_invoke;
+ if (hdr_name != NULL && valid_skill_name(hdr_name)) {
+ size_t nlen = strlen(hdr_name) + 2;
+
+ s.name = xmalloc(nlen);
+ (void)snprintf(s.name, nlen, "/%s", hdr_name);
+ free(hdr_name);
+ } else {
+ size_t nlen = strlen(dirname) + 2;
+
+ s.name = xmalloc(nlen);
+ (void)snprintf(s.name, nlen, "/%s", dirname);
+ free(hdr_name);
+ }
+
+ sk->items = xreallocarray(sk->items, sk->n + 1, sizeof(*sk->items));
+ sk->items[sk->n++] = s;
+}
+
+static int
+cmp_skill(const void *a, const void *b)
+{
+ const struct skill *sa = a, *sb = b;
+
+ return (strcmp(sa->name, sb->name));
+}
+
+void
+skills_load(struct skills *sk)
+{
+ DIR *d;
+ struct dirent *de;
+ char root[PATH_MAX], path[PATH_MAX];
+
+ if (skills_dir(root, sizeof(root)) == -1)
+ return;
+ if ((d = opendir(root)) == NULL)
+ return;
+ while ((de = readdir(d)) != NULL && sk->n < SKILLS_MAX) {
+ if (!valid_skill_name(de->d_name))
+ continue;
+ if (snprintf(path, sizeof(path), "%s/%s/SKILL.md", root,
+ de->d_name) >= (int)sizeof(path))
+ continue;
+ load_one(sk, de->d_name, path);
+ }
+ closedir(d);
+
+ if (sk->n > 1)
+ qsort(sk->items, sk->n, sizeof(*sk->items), cmp_skill);
+}
+
+void
+skills_free(struct skills *sk)
+{
+ size_t i;
+
+ for (i = 0; i < sk->n; i++) {
+ free(sk->items[i].name);
+ free(sk->items[i].description);
+ free(sk->items[i].body);
+ }
+ free(sk->items);
+ sk->items = NULL;
+ sk->n = 0;
+}
+
+const struct skill *
+skills_match(const struct skills *sk, const char *line, const char **trailing)
+{
+ const char *p, *space;
+ size_t i, namelen;
+
+ if (trailing != NULL)
+ *trailing = NULL;
+ if (sk == NULL || sk->n == 0 || line == NULL || line[0] != '/')
+ return (NULL);
+
+ /* Find end of the first whitespace-delimited token. */
+ for (p = line; *p != '\0' && !isspace((unsigned char)*p); p++)
+ ;
+ namelen = (size_t)(p - line);
+ space = p;
+
+ for (i = 0; i < sk->n; i++) {
+ const struct skill *s = &sk->items[i];
+
+ if (strlen(s->name) == namelen &&
+ strncmp(s->name, line, namelen) == 0) {
+ if (trailing != NULL) {
+ while (*space != '\0' &&
+ isspace((unsigned char)*space))
+ space++;
+ if (*space != '\0')
+ *trailing = space;
+ }
+ return (s);
+ }
+ }
+ return (NULL);
+}
+
+int
+skills_complete(const struct skills *sk, const char *prefix,
+ const char **matches, int max)
+{
+ size_t plen = strlen(prefix), i;
+ int n = 0;
+
+ if (sk == NULL)
+ return (0);
+ for (i = 0; i < sk->n && n < max; i++)
+ if (strncmp(sk->items[i].name, prefix, plen) == 0)
+ matches[n++] = sk->items[i].name;
+ return (n);
+}
+
+/*
+ * Journal record for a skill invocation. Mirrors the /clear and /compact
+ * markers in convo.c: a {"fugu":"skill","name":"<slash-name>"} line written
+ * just before the user message carrying the body, so resume can replay
+ * faithfully (the user message itself reconstitutes the conversation state;
+ * the marker is informational, for transcripts and future tooling).
+ */
+void
+skills_marker(struct buf *out, const char *slash_name)
+{
+ struct json_writer w;
+
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "fugu", "skill");
+ json_kv_string(&w, "name", slash_name);
+ json_object_end(&w);
+}
+
+/*
+ * Emit ", { ... }" -- one Messages-API tool definition naming the `skill` tool
+ * with the bare names of every model-invocable skill in the enum, and a
+ * description that lists each skill's name and one-line summary so the model
+ * has a per-skill matching signal. Net concatenates this onto TOOLS_BASE
+ * before closing the array, so the leading comma is part of our payload.
+ */
+void
+skills_tool_fragment(const struct skills *sk, struct buf *out)
+{
+ struct json_writer w;
+ struct buf desc;
+ size_t i, count = 0;
+
+ if (sk == NULL || sk->n == 0)
+ return;
+ for (i = 0; i < sk->n; i++)
+ if (!sk->items[i].no_model_invoke)
+ count++;
+ if (count == 0)
+ return;
+
+ buf_init(&desc);
+ buf_puts_cstr(&desc, "Invoke an installed skill -- a procedural template "
+ "the user has placed in ~/.fugu/skills/. The skill body is "
+ "returned as the tool result; follow its instructions and drive "
+ "the resulting workflow.\n\nAvailable skills:\n");
+ for (i = 0; i < sk->n; i++) {
+ const struct skill *s = &sk->items[i];
+
+ if (s->no_model_invoke)
+ continue;
+ buf_printf(&desc, "- %s: %s\n", s->name + 1,
+ s->description != NULL ? s->description : "(no description)");
+ }
+ buf_terminate(&desc);
+
+ buf_putc(out, ',');
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "name", "skill");
+ json_kv_string(&w, "description", desc.data);
+ json_key(&w, "input_schema");
+ json_object_start(&w);
+ json_kv_string(&w, "type", "object");
+ json_key(&w, "properties");
+ json_object_start(&w);
+ json_key(&w, "name");
+ json_object_start(&w);
+ json_kv_string(&w, "type", "string");
+ json_kv_string(&w, "description", "Bare name of the skill (no leading "
+ "slash).");
+ json_key(&w, "enum");
+ json_array_start(&w);
+ for (i = 0; i < sk->n; i++)
+ if (!sk->items[i].no_model_invoke)
+ json_string(&w, sk->items[i].name + 1);
+ json_array_end(&w);
+ json_object_end(&w);
+ json_object_end(&w);
+ json_key(&w, "required");
+ json_array_start(&w);
+ json_string(&w, "name");
+ json_array_end(&w);
+ json_object_end(&w);
+ json_object_end(&w);
+
+ buf_free(&desc);
+}
+
+/*
+ * Build a tool_use_id of the form "fsk_<sec>_<n>" -- unique within a session
+ * (time_t for the day-of, counter for ties) and within an unbroken time/pid
+ * stretch. The provider only cares that the id is non-empty and that the
+ * matching tool_use/tool_result pair share it.
+ */
+static void
+gen_tool_id(char *out, size_t sz)
+{
+ static unsigned int counter;
+
+ (void)snprintf(out, sz, "fsk_%lld_%u", (long long)time(NULL),
+ counter++);
+}
+
+void
+skills_synth_invocation(const struct skill *s, const char *typed_line,
+ const char *trailing, struct buf *user_pre, struct buf *assist_msg,
+ struct buf *user_msg, char *id_out, size_t id_sz)
+{
+ struct json_writer w;
+ struct buf body;
+ const char *bare;
+
+ gen_tool_id(id_out, id_sz);
+ bare = s->name[0] == '/' ? s->name + 1 : s->name;
+
+ /*
+ * Leading user message carrying the literal typed slash command -- the
+ * Messages array MUST begin with a user message, even on the first
+ * turn or right after /compact (last message is an assistant summary).
+ */
+ json_writer_init(&w, user_pre);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "user");
+ json_kv_string(&w, "content", typed_line != NULL ? typed_line : "");
+ json_object_end(&w);
+ buf_terminate(user_pre);
+
+ /* Assistant message: a single tool_use block addressed to `skill`. */
+ json_writer_init(&w, assist_msg);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "assistant");
+ json_key(&w, "content");
+ json_array_start(&w);
+ json_object_start(&w);
+ json_kv_string(&w, "type", "tool_use");
+ json_kv_string(&w, "id", id_out);
+ json_kv_string(&w, "name", "skill");
+ json_key(&w, "input");
+ json_object_start(&w);
+ json_kv_string(&w, "name", bare);
+ json_object_end(&w);
+ json_object_end(&w);
+ json_array_end(&w);
+ json_object_end(&w);
+ buf_terminate(assist_msg);
+
+ /* Body, plus any trailing argument as a follow-on paragraph. */
+ buf_init(&body);
+ buf_puts_cstr(&body, s->body);
+ if (trailing != NULL && *trailing != '\0') {
+ buf_puts_cstr(&body, "\n\n");
+ buf_puts_cstr(&body, trailing);
+ }
+ buf_terminate(&body);
+
+ /* User message: the matching tool_result block carrying the body. */
+ json_writer_init(&w, user_msg);
+ json_object_start(&w);
+ json_kv_string(&w, "role", "user");
+ json_key(&w, "content");
+ json_array_start(&w);
+ json_object_start(&w);
+ json_kv_string(&w, "type", "tool_result");
+ json_kv_string(&w, "tool_use_id", id_out);
+ json_kv_string(&w, "content", body.data);
+ json_object_end(&w);
+ json_array_end(&w);
+ json_object_end(&w);
+ buf_terminate(user_msg);
+
+ buf_free(&body);
+}
+
+/* Extract a string-typed object member as an owned string. */
+static char *
+obj_getstr(const struct json_doc *d, int obj, const char *key)
+{
+ int t = json_obj_get(d, obj, key);
+
+ if (t < 0 || json_tok_type(d, t) != JSON_STRING)
+ return (NULL);
+ return (json_tok_strdup(d, t));
+}
+
+void
+skills_handle_tool_request(const struct skills *sk, const void *data,
+ size_t len, struct buf *out)
+{
+ struct json_doc d;
+ struct json_writer w;
+ const struct skill *hit = NULL;
+ char *id = NULL, *bare = NULL;
+ int input, is_error = 1;
+ size_t i;
+ const char *content = "skill not found";
+
+ if (json_parse(&d, data, len) != 0 || d.ntok < 1 ||
+ json_tok_type(&d, 0) != JSON_OBJECT)
+ goto reply;
+ id = obj_getstr(&d, 0, "id");
+ input = json_obj_get(&d, 0, "input");
+ if (input >= 0)
+ bare = obj_getstr(&d, input, "name");
+ if (bare != NULL && sk != NULL) {
+ for (i = 0; i < sk->n; i++) {
+ const struct skill *s = &sk->items[i];
+
+ if (strcmp(s->name + 1, bare) != 0)
+ continue;
+ /*
+ * A skill flagged disable-model-invocation is reachable
+ * only via the user's slash command; treat a model name
+ * match as a miss so the reply is "skill not found".
+ */
+ if (s->no_model_invoke)
+ continue;
+ hit = s;
+ break;
+ }
+ }
+ if (hit != NULL) {
+ content = hit->body;
+ is_error = 0;
+ }
+
+reply:
+ json_writer_init(&w, out);
+ json_object_start(&w);
+ json_kv_string(&w, "id", id != NULL ? id : "");
+ json_kv_string(&w, "content", content);
+ json_kv_bool(&w, "is_error", is_error);
+ json_object_end(&w);
+
+ free(id);
+ free(bare);
+ json_doc_free(&d);
+}
blob - /dev/null
blob + 4746be5dbb6f7df9459c41bb08205ec0c0972bc5 (mode 644)
--- /dev/null
+++ src/ui/skills.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * skills: markdown prompt templates the user invokes by slash-name. Each lives
+ * at ~/.fugu/skills/<name>/SKILL.md with an optional YAML-ish header
+ *
+ * ---
+ * name: <name>
+ * description: <one line>
+ * ---
+ * <body the model sees>
+ *
+ * On invocation the body (plus any trailing argument the user typed after the
+ * name) is appended to the conversation as a user message and submitted -- no
+ * change to the request shape that fugu-net sends to the provider. Loading is
+ * best-effort like the session journal: anything malformed or unreadable is
+ * silently skipped.
+ */
+
+#ifndef FUGU_SKILLS_H
+#define FUGU_SKILLS_H
+
+#include <sys/types.h>
+
+struct skill {
+ char *name; /* slash-form, e.g. "/triage" */
+ char *description; /* one-line summary, may be NULL */
+ char *body; /* prompt body sent as the user message */
+ int no_model_invoke;/* header `disable-model-invocation: true` */
+};
+
+struct skills {
+ struct skill *items;
+ size_t n;
+};
+
+/*
+ * unveil(2) the skills directory readable (best-effort); call before locking,
+ * alongside journal_unveil().
+ */
+void skills_unveil(void);
+
+/*
+ * Scan ~/.fugu/skills/ and populate sk (zeroed by the caller). Items sorted
+ * alphabetically. Anything malformed -- missing SKILL.md, empty body, oversize
+ * file, header without a closing fence -- is silently skipped.
+ */
+void skills_load(struct skills *sk);
+
+void skills_free(struct skills *sk);
+
+/*
+ * If `line` begins with a slash-name that matches a loaded skill, return it.
+ * On match, *trailing (if non-NULL) is set to the start of any non-whitespace
+ * text after the name, or NULL if the user typed only the name. Returns NULL
+ * on no match.
+ */
+const struct skill *skills_match(const struct skills *sk, const char *line,
+ const char **trailing);
+
+/*
+ * Append skill slash-names having `prefix` to matches[] (up to max appended).
+ * Returns the count appended. Borrowed names -- the caller must not free them.
+ */
+int skills_complete(const struct skills *sk, const char *prefix,
+ const char **matches, int max);
+
+/* Build the journal marker recording a /<skill-name> invocation. */
+struct buf;
+void skills_marker(struct buf *out, const char *slash_name);
+
+/*
+ * Emit the comma-prefixed JSON fragment that registers the model-invocable
+ * `skill` tool with net (which appends it to its TOOLS_BASE before sending the
+ * Messages API request). Empty buffer when sk has no model-invocable skills.
+ * The fragment has no enclosing brackets; net inserts it before its closing ].
+ */
+void skills_tool_fragment(const struct skills *sk, struct buf *out);
+
+/*
+ * Synthesise a user-driven skill invocation as a user/assistant/user triple the
+ * model will see as having already happened:
+ * {"role":"user","content":"<typed_line>"}
+ * {"role":"assistant","content":[{"type":"tool_use","id":<id>,
+ * "name":"skill","input":{"name":"<bare-name>"}}]}
+ * {"role":"user","content":[{"type":"tool_result","tool_use_id":<id>,
+ * "content":"<body[\n\ntrailing]>"}]}
+ * The leading user message carries the user's literal typed slash command (not
+ * the body) so the resulting messages array always begins with a user message
+ * -- required by Anthropic Messages and strict OpenAI servers, even on the
+ * first turn of a fresh session or right after /compact. All three messages
+ * are written into the caller's bufs (NUL-terminated like all convo entries).
+ * *id_out (caller-allocated, at least 32 bytes) gets the generated tool_use_id
+ * so the caller can keep them paired in the journal.
+ */
+void skills_synth_invocation(const struct skill *sk, const char *typed_line,
+ const char *trailing, struct buf *user_pre,
+ struct buf *assist_msg, struct buf *user_msg, char *id_out,
+ size_t id_sz);
+
+/*
+ * Service a model-issued tool_use("skill") request. data/len is the
+ * M_TOOL_REQUEST payload ({"id","name":"skill","input":{"name":"<bare>"}});
+ * out is filled with the M_TOOL_RESULT payload
+ * ({"id","content":"<body>","is_error":bool}). Always produces a syntactically
+ * valid result -- on lookup failure, an is_error result naming the bad skill.
+ */
+void skills_handle_tool_request(const struct skills *sk, const void *data,
+ size_t len, struct buf *out);
+
+#endif /* FUGU_SKILLS_H */
blob - /dev/null
blob + 35ee38a1b98b93491e4e2d2b1993086ff5f70af6 (mode 644)
--- /dev/null
+++ src/ui/ui.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-ui: the user-interface worker (${PREFIX}/libexec/fugu/fugu-ui).
+ *
+ * On a real terminal it runs the curses front-end (ui_curses.c); otherwise (a
+ * pipe, the regress harness, a non-terminal stdout) it falls back to the
+ * line-mode reader here. Both share the conversation model and the session
+ * journal, and speak the same imsg protocol to net via the parent; the imsg
+ * channel arrives on fd 3.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "journal.h"
+#include "log.h"
+#include "skills.h"
+#include "ui.h"
+#include "util.h"
+
+#define FUGU_UI_IMSG_FD 3
+
+/* Print one replayed transcript part when resuming a session in line mode. */
+static void
+line_replay(void *ctx, enum convo_part part, const char *text, size_t len)
+{
+ const char *pfx;
+
+ (void)ctx;
+ if (part == CP_INFO) {
+ printf("-- %.*s --\n", (int)len, text);
+ return;
+ }
+ switch (part) {
+ case CP_USER:
+ pfx = "you> ";
+ break;
+ case CP_TOOL:
+ pfx = "tool: ";
+ break;
+ default:
+ pfx = "fugu> ";
+ break;
+ }
+ printf("%s%.*s\n", pfx, (int)len, text);
+}
+
+/*
+ * The slash commands, in completion order. Single source of truth for both
+ * ui_classify() (exact dispatch) and ui_complete() (Tab completion); /exit and
+ * /quit are aliases for the same action. New commands -- including any later
+ * contributed dynamically by skills -- need only be added here.
+ */
+static const struct ui_command {
+ const char *name;
+ enum ui_cmd cmd;
+} UI_COMMANDS[] = {
+ { "/clear", UC_CLEAR },
+ { "/compact", UC_COMPACT },
+ { "/exit", UC_QUIT },
+ { "/model", UC_MODELS },
+ { "/quit", UC_QUIT },
+};
+#define NUI_COMMANDS (sizeof(UI_COMMANDS) / sizeof(UI_COMMANDS[0]))
+
+enum ui_cmd
+ui_classify(const char *line)
+{
+ size_t i;
+
+ for (i = 0; i < NUI_COMMANDS; i++)
+ if (strcmp(line, UI_COMMANDS[i].name) == 0)
+ return (UI_COMMANDS[i].cmd);
+ return (UC_NONE);
+}
+
+/*
+ * Fill matches[] (up to max entries) with the command names having `prefix` as
+ * a prefix -- built-ins in their static order first, then loaded skill names
+ * alphabetically. Returns the count. Names are borrowed (static storage or
+ * owned by the skills registry) -- the caller must not free them.
+ */
+int
+ui_complete(const struct skills *sk, const char *prefix, const char **matches,
+ int max)
+{
+ size_t plen = strlen(prefix);
+ size_t i;
+ int n = 0;
+
+ for (i = 0; i < NUI_COMMANDS && n < max; i++)
+ if (strncmp(UI_COMMANDS[i].name, prefix, plen) == 0)
+ matches[n++] = UI_COMMANDS[i].name;
+ if (n < max)
+ n += skills_complete(sk, prefix, matches + n, max - n);
+ return (n);
+}
+
+/*
+ * Service a model-invoked `skill` tool call: net forwards us the M_TOOL_REQUEST
+ * (its tool_dst routes "skill" to EP_UI); we look the skill up in sk and reply
+ * with M_TOOL_RESULT. A request for an unknown skill returns an is_error
+ * result so the model sees the failure rather than the channel hanging.
+ * Returns -1 if the imsg send failed -- net is then blocked waiting on a result
+ * that will never arrive, so the caller treats it as a channel break.
+ */
+static int
+line_service_tool(struct imsgproto *ip, const struct skills *sk,
+ const void *data, size_t len)
+{
+ struct buf out;
+ int rc = 0;
+
+ buf_init(&out);
+ skills_handle_tool_request(sk, data, len, &out);
+ if (ip_compose(ip, M_TOOL_RESULT, EP_NET, EP_UI, out.data,
+ out.len) == -1 || ip_flush(ip) == -1) {
+ log_warnx("tool result send failed");
+ rc = -1;
+ }
+ buf_free(&out);
+ return (rc);
+}
+
+/* Stream one reply to stdout. 0 normal, 1 the turn errored, -1 channel closed. */
+static int
+line_collect(struct imsgproto *ip, struct convo *cv, struct journal *j,
+ const struct skills *sk)
+{
+ struct fugu_msg m;
+ int r;
+
+ for (;;) {
+ r = ip_get(ip, &m);
+ if (r == -1)
+ return (-1);
+ if (r == 0) {
+ if (ip_read(ip) <= 0)
+ return (-1);
+ continue;
+ }
+ switch (m.type) {
+ case M_DELTA:
+ fwrite(m.data, 1, m.len, stdout);
+ fflush(stdout);
+ break;
+ case M_MESSAGE:
+ convo_add_raw(cv, (char *)m.data, m.len);
+ journal_append(j, (char *)m.data, m.len);
+ break;
+ case M_TOOL_CALL:
+ fprintf(stderr, "\n[tool] %.*s\n", (int)m.len,
+ (char *)m.data);
+ break;
+ case M_TOOL_REQUEST:
+ if (line_service_tool(ip, sk, m.data, m.len) == -1) {
+ ip_msg_free(&m);
+ return (-1);
+ }
+ break;
+ case M_TURN_DONE:
+ fputc('\n', stdout);
+ fflush(stdout);
+ ip_msg_free(&m);
+ return (0);
+ case M_ERROR:
+ fprintf(stderr, "\n[error] %.*s\n", (int)m.len,
+ (char *)m.data);
+ ip_msg_free(&m);
+ return (1);
+ default:
+ break;
+ }
+ ip_msg_free(&m);
+ }
+}
+
+/* Wait for a /compact summary. 0 ok (out filled), 1 errored, -1 channel closed. */
+static int
+line_compact(struct imsgproto *ip, struct buf *out)
+{
+ struct fugu_msg m;
+ int r;
+
+ for (;;) {
+ r = ip_get(ip, &m);
+ if (r == -1)
+ return (-1);
+ if (r == 0) {
+ if (ip_read(ip) <= 0)
+ return (-1);
+ continue;
+ }
+ switch (m.type) {
+ case M_SUMMARY:
+ buf_append(out, m.data, m.len);
+ ip_msg_free(&m);
+ return (0);
+ case M_ERROR:
+ fprintf(stderr, "\n[error] %.*s\n", (int)m.len,
+ (char *)m.data);
+ ip_msg_free(&m);
+ return (1);
+ default:
+ break; /* ignore stray usage/etc. */
+ }
+ ip_msg_free(&m);
+ }
+}
+
+int
+ui_line_run(struct imsgproto *ip, struct convo *cv, struct journal *j,
+ const struct skills *sk, const char *resume_id)
+{
+ struct buf msgs;
+ char *line = NULL;
+ size_t lcap = 0, mark;
+ ssize_t ll;
+ int r;
+
+ journal_unveil();
+ if (unveil(NULL, NULL) == -1)
+ fatal("unveil lock");
+ if (pledge("stdio rpath wpath cpath", NULL) == -1)
+ fatal("pledge");
+ if (resume_id != NULL) {
+ printf("-- resumed session %s --\n", resume_id);
+ if (convo_resume(cv, resume_id, line_replay, NULL) == -1)
+ fprintf(stderr, "fugu: cannot read session %s\n",
+ resume_id);
+ journal_open(j, resume_id);
+ } else
+ journal_open(j, NULL);
+ if (pledge("stdio", NULL) == -1) /* the journal fd stays writable */
+ fatal("pledge");
+
+ for (;;) {
+ enum ui_cmd cmd;
+
+ fputs("> ", stdout);
+ fflush(stdout);
+
+ if ((ll = getline(&line, &lcap, stdin)) == -1)
+ break;
+ while (ll > 0 && (line[ll - 1] == '\n' || line[ll - 1] == '\r'))
+ line[--ll] = '\0';
+ if (ll == 0)
+ continue;
+
+ cmd = ui_classify(line);
+ if (cmd == UC_QUIT)
+ break;
+ if (cmd == UC_MODELS) {
+ fputs("-- /model is only available in the interactive "
+ "UI --\n", stdout);
+ continue;
+ }
+ if (cmd == UC_CLEAR) {
+ struct buf mk;
+
+ buf_init(&mk);
+ convo_marker_clear(&mk);
+ journal_append(j, mk.data, mk.len);
+ buf_free(&mk);
+ convo_clear(cv);
+ fputs("-- context cleared --\n", stdout);
+ continue;
+ }
+ if (cmd == UC_COMPACT) {
+ struct buf sum;
+
+ if (cv->n == 0) {
+ fputs("-- nothing to compact --\n", stdout);
+ continue;
+ }
+ buf_init(&msgs);
+ convo_build(cv, &msgs);
+ if (ip_compose(ip, M_SUMMARIZE, EP_NET, EP_UI, msgs.data,
+ msgs.len) == -1 || ip_flush(ip) == -1) {
+ buf_free(&msgs);
+ break;
+ }
+ buf_free(&msgs);
+
+ buf_init(&sum);
+ r = line_compact(ip, &sum);
+ if (r == 0 && sum.len > 0) {
+ struct buf mk;
+
+ buf_terminate(&sum);
+ buf_init(&mk);
+ convo_marker_compact(&mk, sum.data);
+ journal_append(j, mk.data, mk.len);
+ buf_free(&mk);
+ convo_set_summary(cv, sum.data);
+ printf("-- compacted --\n%s\n", sum.data);
+ }
+ buf_free(&sum);
+ if (r == -1)
+ break;
+ continue;
+ }
+
+ /*
+ * A leading slash that isn't a built-in: look it up in the
+ * skills registry. On a hit, synthesise an assistant tool_use
+ * + user tool_result pair so the model receives the body as
+ * authoritative retrieved instructions (the strongest framing
+ * for follow-through). Journal records: marker, then both
+ * synthesised messages. On a miss, fall through to the normal
+ * user-message path.
+ */
+ {
+ const struct skill *match = NULL;
+ const char *trailing = NULL;
+
+ if (line[0] == '/' &&
+ (match = skills_match(sk, line, &trailing)) != NULL) {
+ struct buf mk, up, am, um;
+ char id[40];
+
+ buf_init(&mk);
+ skills_marker(&mk, match->name);
+ journal_append(j, mk.data, mk.len);
+ buf_free(&mk);
+
+ buf_init(&up);
+ buf_init(&am);
+ buf_init(&um);
+ skills_synth_invocation(match, line, trailing,
+ &up, &am, &um, id, sizeof(id));
+
+ printf("-- %s --\n", match->name);
+
+ mark = cv->n;
+ convo_add_raw(cv, up.data, up.len);
+ convo_add_raw(cv, am.data, am.len);
+ convo_add_raw(cv, um.data, um.len);
+ journal_append(j, up.data, up.len);
+ journal_append(j, am.data, am.len);
+ journal_append(j, um.data, um.len);
+ buf_free(&up);
+ buf_free(&am);
+ buf_free(&um);
+ } else {
+ mark = cv->n;
+ convo_add_user(cv, line);
+ journal_append(j, cv->msg[cv->n - 1],
+ strlen(cv->msg[cv->n - 1]));
+ }
+ }
+
+ buf_init(&msgs);
+ convo_build(cv, &msgs);
+ if (ip_compose(ip, M_SUBMIT, EP_NET, EP_UI, msgs.data,
+ msgs.len) == -1 || ip_flush(ip) == -1) {
+ /*
+ * Submit went out the wire only partially (or not at
+ * all), but cv + journal already have the user message
+ * (or skill synth trio) for this turn. Mark first
+ * (the helper guards on cv->n > mark), THEN truncate.
+ */
+ convo_journal_turn_failed(j, cv, mark);
+ convo_truncate(cv, mark);
+ buf_free(&msgs);
+ break;
+ }
+ buf_free(&msgs);
+
+ r = line_collect(ip, cv, j, sk);
+ if (r == -1) {
+ /*
+ * Channel break mid-turn (tool-reply send failed, peer
+ * closed, read error). The user message and any
+ * M_MESSAGE blocks already in cv + journal would
+ * replay as orphans on resume -- mark the turn failed.
+ */
+ convo_journal_turn_failed(j, cv, mark);
+ convo_truncate(cv, mark);
+ break;
+ }
+ if (r == 1) { /* failed turn: journal then undo */
+ convo_journal_turn_failed(j, cv, mark);
+ convo_truncate(cv, mark);
+ }
+ }
+
+ free(line);
+ return (0);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgproto ip;
+ struct convo cv;
+ struct journal j;
+ struct skills sk;
+ struct stat st;
+ extern char *__progname;
+ const char *resume_id = NULL;
+ int ch, rc, tty, ascii = 0;
+
+ log_init(LOG_TO_SYSLOG, 0, LOG_USER);
+ signal(SIGPIPE, SIG_IGN);
+ drop_privgroup(); /* never need the parent's setgid _fugu group */
+
+ while ((ch = getopt(argc, argv, "aR:")) != -1) {
+ switch (ch) {
+ case 'a': /* ASCII-only display (parent) */
+ ascii = 1;
+ break;
+ case 'R': /* resume session id (parent) */
+ resume_id = optarg;
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (fstat(FUGU_UI_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+ fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+ "directly\n", __progname);
+ return (1);
+ }
+ if (ip_init(&ip, FUGU_UI_IMSG_FD) == -1)
+ fatal("ip_init");
+
+ memset(&cv, 0, sizeof(cv));
+ memset(&sk, 0, sizeof(sk));
+ j.fd = -1;
+ tty = isatty(STDIN_FILENO) && isatty(STDOUT_FILENO) &&
+ getenv("TERM") != NULL;
+
+ /*
+ * Load skills (~/.fugu/skills/) once, before either front-end pledges
+ * or unveils. Slurping bodies into memory now means the runtime needs
+ * neither rpath nor a peek at the skills directory.
+ */
+ skills_load(&sk);
+
+ if (ip_compose(&ip, M_READY, EP_PARENT, EP_UI, NULL, 0) == -1 ||
+ ip_flush(&ip) == -1)
+ fatalx("ui: failed to announce readiness");
+
+ /*
+ * Register the `skill` tool with net so the model can invoke it. The
+ * fragment is empty when no skills are model-invocable, and net treats
+ * an empty M_TOOLS_ADD as a no-op. Routing of incoming tool_use("skill")
+ * back to ui is handled by net's tool_dst() lookup.
+ */
+ {
+ struct buf tools;
+
+ buf_init(&tools);
+ skills_tool_fragment(&sk, &tools);
+ if (tools.len > 0 &&
+ (ip_compose(&ip, M_TOOLS_ADD, EP_NET, EP_UI, tools.data,
+ tools.len) == -1 || ip_flush(&ip) == -1))
+ fatalx("ui: failed to register skill tool");
+ buf_free(&tools);
+ }
+
+ /* Each front-end opens the journal and narrows its own pledge. */
+ if (tty)
+ rc = ui_curses_run(&ip, &cv, &j, &sk, ascii, resume_id);
+ else
+ rc = ui_line_run(&ip, &cv, &j, &sk, resume_id);
+
+ journal_close(&j);
+ convo_free(&cv);
+ skills_free(&sk);
+ ip_clear(&ip);
+ return (rc);
+}
blob - /dev/null
blob + 1cfbd046e7102c8c6ce46b67c966f0858ec2a7d1 (mode 644)
--- /dev/null
+++ src/ui/ui.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_UI_H
+#define FUGU_UI_H
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "journal.h"
+#include "skills.h"
+
+/*
+ * The conversation the UI maintains and re-sends as the M_SUBMIT projection,
+ * and persists to the journal. Each entry is one complete message object as
+ * raw JSON ({"role":...,"content":...}): the user's typed messages are built
+ * here, the assistant and tool_result messages arrive from net (M_MESSAGE) with
+ * their full content (text + tool_use / tool_result blocks), so the history is
+ * faithful across turns. Shared by both front-ends (ui.c, ui_curses.c).
+ */
+struct convo {
+ char **msg; /* each a NUL-terminated raw JSON message object */
+ size_t n;
+};
+
+/* Append a user message built from plain text. */
+void convo_add_user(struct convo *, const char *text);
+/* Append a raw message object (copied) as received from net. */
+void convo_add_raw(struct convo *, const char *json, size_t len);
+/* Drop messages down to n (undo a failed turn). */
+void convo_truncate(struct convo *, size_t n);
+void convo_free(struct convo *);
+void convo_build(const struct convo *, struct buf *out); /* messages[] */
+
+/* /clear: reset the active context to empty (the journal is untouched). */
+void convo_clear(struct convo *);
+/* /compact: replace the active context with a summary (user/assistant pair). */
+void convo_set_summary(struct convo *, const char *summary);
+/* Build the journal records marking a /clear and a /compact (with summary). */
+void convo_marker_clear(struct buf *out);
+void convo_marker_compact(struct buf *out, const char *summary);
+/* Mark a turn that failed mid-flight; on resume drops the orphan records. */
+void convo_marker_turn_failed(struct buf *out);
+/*
+ * Append a turn_failed marker iff the failed turn wrote records past `mark`.
+ * The single choke point both UIs use: avoids over-emitting (no records to
+ * undo -> would roll back past a prior good turn) and under-emitting (orphan
+ * records replay on resume with nothing to discard them).
+ */
+void convo_journal_turn_failed(struct journal *,
+ const struct convo *, size_t mark);
+
+/*
+ * Resume / transcript replay. A stored message decomposes into renderable
+ * parts; each front-end maps these onto its own display (curses blocks, or
+ * line-mode prefixes). CP_INFO is a divider (e.g. a replayed /clear or
+ * /compact marker); tool_result blocks are kept in the conversation but not
+ * replayed (they were never drawn live either).
+ */
+enum convo_part { CP_USER, CP_ASSIST, CP_TOOL, CP_INFO };
+typedef void (*convo_part_cb)(void *ctx, enum convo_part, const char *text,
+ size_t len);
+
+/* Slash commands recognised by both front-ends. */
+enum ui_cmd { UC_NONE, UC_CLEAR, UC_COMPACT, UC_QUIT, UC_MODELS };
+enum ui_cmd ui_classify(const char *line);
+
+/*
+ * Tab completion: fill matches[] (up to max) with the command names having
+ * `prefix` (a leading "/...") as a prefix, built-ins first then loaded skills,
+ * alphabetical within each; return the count. `sk` may be NULL. Borrowed
+ * static / loaded names -- do not free.
+ */
+int ui_complete(const struct skills *sk, const char *prefix,
+ const char **matches, int max);
+
+/* Decompose one raw message object into display parts via cb. */
+void convo_replay(const char *json, size_t len, convo_part_cb, void *ctx);
+/*
+ * Load session <id>'s journal into cv (convo_add_raw per line) and replay each
+ * message through cb for display. 0 on success, -1 if the session is unreadable.
+ */
+int convo_resume(struct convo *, const char *id, convo_part_cb, void *ctx);
+
+/* Line-mode front-end, used when stdio is not an interactive terminal. */
+int ui_line_run(struct imsgproto *, struct convo *, struct journal *,
+ const struct skills *sk, const char *resume_id);
+
+/*
+ * Curses front-end, used on a real terminal. Narrows pledge to "stdio tty".
+ * ascii: render the transcript/input transliterated to ASCII (no UTF-8 glyphs).
+ * sk: loaded skills registry for slash dispatch and Tab completion (may be NULL).
+ * resume_id: a session to reopen and replay, or NULL to start fresh.
+ */
+int ui_curses_run(struct imsgproto *, struct convo *, struct journal *,
+ const struct skills *sk, int ascii, const char *resume_id);
+
+#endif /* FUGU_UI_H */
blob - /dev/null
blob + ccc6855e24cd1e4c165a3a60f9f14ea3c044e266 (mode 644)
--- /dev/null
+++ src/ui/ui_curses.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The curses front-end: a scrolling transcript above a one-line input editor,
+ * with a status bar between them. A poll(2) loop multiplexes the terminal (fd
+ * 0) and the imsg channel to net (fd 3): keystrokes edit/submit the input, and
+ * streamed M_DELTA/M_TOOL_CALL/M_USAGE/M_TURN_DONE/M_ERROR messages update the
+ * transcript live.
+ *
+ * Text is handled as wide characters: block text (UTF-8 bytes from net) is
+ * decoded with mbrtowc(3) for rendering, wrapped by wcwidth(3) display columns,
+ * and drawn with the wide curses API, while the input editor stores wchar_t and
+ * is read with get_wch(3) -- so multi-byte UTF-8 renders as one glyph instead of
+ * a row of mojibake. The input is a single logical line. pledge is narrowed to
+ * "stdio tty" once curses has read the terminfo entry.
+ */
+
+#define NCURSES_WIDECHAR 1 /* expose get_wch/addnwstr without _XOPEN_SOURCE */
+
+#include <sys/types.h>
+#include <sys/ioctl.h>
+
+#include <errno.h>
+#include <langinfo.h>
+#include <limits.h>
+#include <locale.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include <curses.h>
+
+#include "buf.h"
+#include "fuzzy.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "mdrender.h"
+#include "ui.h"
+#include "util.h"
+
+enum bkind { BK_INFO, BK_USER, BK_ASSIST, BK_TOOL, BK_ERROR };
+
+/* The input editor is modal: it starts in insert and Esc switches to normal. */
+enum imode { IM_INSERT, IM_NORMAL };
+
+struct block {
+ enum bkind kind;
+ struct buf text; /* UTF-8 bytes */
+};
+
+struct tui {
+ struct imsgproto *ip;
+ struct convo *cv;
+ struct journal *j;
+ const struct skills *sk;
+ size_t cv_mark; /* convo length at turn start */
+ struct block *blocks;
+ size_t nblocks;
+ wchar_t *in; /* input line, wide chars */
+ size_t ilen, icap, icur;
+ int imode; /* IM_INSERT or IM_NORMAL */
+ int pend; /* pending operator (0 or L'd') */
+ int comping; /* mid Tab-completion cycle */
+ int compidx; /* next match in the cycle */
+ char compbase[64]; /* the "/..." prefix being completed */
+ /* model picker (/model): a modal fuzzy-search overlay */
+ int picking; /* picker overlay is active */
+ int loading_models;/* M_MODELS request in flight */
+ char **models; /* all model ids from the provider */
+ size_t nmodels;
+ char pquery[128]; /* fuzzy filter text */
+ size_t pqlen;
+ int *pmatch; /* model indices matching pquery, ranked */
+ size_t npmatch;
+ int psel; /* selected row (index into pmatch) */
+ int ptop; /* first visible row (scroll) */
+ wchar_t **hist; /* prompts sent this session */
+ size_t *histlen; /* length of each hist entry */
+ size_t nhist;
+ size_t histpos; /* == nhist when not browsing */
+ wchar_t *histsave; /* live line saved while browsing*/
+ size_t histsavelen;
+ int busy;
+ int compacting; /* awaiting a /compact summary */
+ int scroll, follow;
+ int quit;
+ int ascii; /* transliterate to ASCII */
+ int rows, cols;
+ char status[160];
+};
+
+static volatile sig_atomic_t resized;
+
+static void on_winch(int);
+static size_t block_new(struct tui *, enum bkind);
+static void blocks_free(struct tui *);
+static void replay_block(void *, enum convo_part, const char *, size_t);
+static const char *prefix_of(enum bkind);
+static int attr_of(enum bkind);
+static int md_attr(int);
+static size_t block_display(struct tui *, struct block *, wchar_t **, int **);
+static int cw(wchar_t);
+static const char *ascii_rep(wchar_t);
+static wchar_t *fold_wcs(const wchar_t *, size_t, size_t *);
+static wchar_t *utf8_to_wcs(const char *, size_t, size_t *);
+static int render(struct tui *, int draw, int vfrom, int vto);
+static void draw(struct tui *);
+static void draw_picker(struct tui *);
+static void request_models(struct tui *);
+static void picker_open(struct tui *, char *, size_t);
+static void picker_close(struct tui *);
+static void picker_free_models(struct tui *);
+static void picker_refilter(struct tui *);
+static void picker_key(struct tui *, wint_t, int);
+static void model_label(const char *, char *, size_t);
+static void do_resize(struct tui *);
+static int drain_imsg(struct tui *);
+static void handle_msg(struct tui *, struct fugu_msg *);
+static void handle_input(struct tui *, wint_t, int iskey);
+static void insert_key(struct tui *, wint_t);
+static void normal_key(struct tui *, wint_t);
+static void in_insert(struct tui *, wchar_t);
+static void in_set(struct tui *, const wchar_t *, size_t);
+static void complete_tab(struct tui *, int);
+static void in_backspace(struct tui *);
+static void in_delch(struct tui *);
+static void del_range(struct tui *, size_t, size_t);
+static void do_dmotion(struct tui *, wint_t);
+static size_t first_nonblank(struct tui *);
+static int wclass(wchar_t);
+static size_t word_fwd(struct tui *, size_t);
+static size_t word_back(struct tui *, size_t);
+static size_t word_end(struct tui *, size_t);
+static int max_scroll(struct tui *);
+static void hist_push(struct tui *);
+static void hist_browse(struct tui *, int dir);
+static void hist_reset(struct tui *);
+static void hist_free(struct tui *);
+static void submit(struct tui *);
+static void do_command(struct tui *, enum ui_cmd);
+
+static void
+on_winch(int sig)
+{
+ (void)sig;
+ resized = 1;
+}
+
+int
+ui_curses_run(struct imsgproto *ip, struct convo *cv, struct journal *j,
+ const struct skills *sk, int ascii, const char *resume_id)
+{
+ struct tui t;
+ struct sigaction sa;
+ struct pollfd pfd[2];
+ size_t b;
+
+ /*
+ * Honour the terminal's encoding, but if the environment did not select
+ * UTF-8, try to enable it anyway: terminals are essentially always UTF-8
+ * now, and the wide curses output below needs a UTF-8 ctype to emit
+ * multi-byte glyphs instead of one cell per byte. The fallbacks are
+ * best-effort -- if no UTF-8 locale exists, setlocale leaves the ctype
+ * unchanged.
+ */
+ setlocale(LC_CTYPE, "");
+ if (strcmp(nl_langinfo(CODESET), "UTF-8") != 0)
+ if (setlocale(LC_CTYPE, "en_US.UTF-8") == NULL)
+ (void)setlocale(LC_CTYPE, "C.UTF-8");
+
+ journal_unveil();
+ if (unveil("/usr/share/terminfo", "r") == -1 && errno != ENOENT)
+ fatal("unveil terminfo");
+ if (unveil(NULL, NULL) == -1)
+ fatal("unveil lock");
+ if (pledge("stdio tty rpath wpath cpath", NULL) == -1)
+ fatal("pledge");
+
+ memset(&t, 0, sizeof(t));
+ t.ip = ip;
+ t.cv = cv;
+ t.j = j;
+ t.sk = sk;
+ t.ascii = ascii;
+ t.follow = 1;
+ strlcpy(t.status, "ready", sizeof(t.status));
+
+ /*
+ * Resume (which reads the journal) and reopen it for appending now, while
+ * rpath is still held; replayed messages become transcript blocks.
+ */
+ if (resume_id != NULL) {
+ if (convo_resume(cv, resume_id, replay_block, &t) == -1) {
+ b = block_new(&t, BK_ERROR);
+ buf_printf(&t.blocks[b].text,
+ "cannot read session %s", resume_id);
+ }
+ journal_open(j, resume_id);
+ } else
+ journal_open(j, NULL);
+
+ b = block_new(&t, BK_INFO);
+ if (resume_id != NULL && j->id[0] != '\0')
+ buf_printf(&t.blocks[b].text, "fugu - resumed session %s "
+ "(%zu messages). Esc for vi normal mode (J/K scroll, "
+ "j/k recall); Ctrl-D quits.", j->id, cv->n);
+ else
+ buf_puts_cstr(&t.blocks[b].text, "fugu - type a message and press "
+ "Enter. Esc for vi normal mode (J/K scroll, j/k recall); "
+ "/clear, /compact, /model (Tab completes /commands); "
+ "Ctrl-D quits.");
+
+ if (initscr() == NULL)
+ fatalx("ui: cannot initialise the terminal");
+ raw();
+ noecho();
+ keypad(stdscr, TRUE);
+ nodelay(stdscr, TRUE);
+ /*
+ * Shorten the wait used to tell a lone Esc (switch to vi normal mode)
+ * from the start of an arrow/function-key sequence. The default is a
+ * full second, which makes Esc feel laggy; 50ms still comfortably spans
+ * a multi-byte sequence arriving in one terminal write.
+ */
+ set_escdelay(50);
+ curs_set(1);
+ getmaxyx(stdscr, t.rows, t.cols);
+
+ /* terminfo has been read; no more file access is needed. */
+ if (pledge("stdio tty", NULL) == -1)
+ fatal("pledge");
+
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = on_winch; /* sa_flags 0: interrupt poll(2) */
+ sigaction(SIGWINCH, &sa, NULL);
+
+ draw(&t);
+ while (!t.quit) {
+ pfd[0].fd = STDIN_FILENO;
+ pfd[0].events = POLLIN;
+ pfd[0].revents = 0;
+ pfd[1].fd = ip_fd(ip);
+ pfd[1].events = POLLIN;
+ pfd[1].revents = 0;
+
+ if (poll(pfd, 2, -1) == -1) {
+ if (errno == EINTR) {
+ if (resized) {
+ resized = 0;
+ do_resize(&t);
+ draw(&t);
+ }
+ continue;
+ }
+ break;
+ }
+ if (resized) {
+ resized = 0;
+ do_resize(&t);
+ }
+ if (pfd[1].revents & (POLLHUP | POLLERR))
+ break;
+ if (pfd[1].revents & POLLIN) {
+ if (drain_imsg(&t) == -1)
+ break;
+ }
+ if (pfd[0].revents & POLLIN) {
+ wint_t wch;
+ int r;
+
+ while ((r = get_wch(&wch)) != ERR)
+ handle_input(&t, wch, r == KEY_CODE_YES);
+ } else if (pfd[0].revents & (POLLHUP | POLLERR))
+ break; /* terminal closed -> exit */
+ draw(&t);
+ }
+
+ endwin();
+ blocks_free(&t);
+ hist_free(&t);
+ picker_free_models(&t);
+ free(t.pmatch);
+ free(t.in);
+ return (0);
+}
+
+static size_t
+block_new(struct tui *t, enum bkind kind)
+{
+ t->blocks = xreallocarray(t->blocks, t->nblocks + 1,
+ sizeof(*t->blocks));
+ t->blocks[t->nblocks].kind = kind;
+ buf_init(&t->blocks[t->nblocks].text);
+ return (t->nblocks++);
+}
+
+static void
+blocks_free(struct tui *t)
+{
+ size_t i;
+
+ for (i = 0; i < t->nblocks; i++)
+ buf_free(&t->blocks[i].text);
+ free(t->blocks);
+ t->blocks = NULL;
+ t->nblocks = 0;
+}
+
+/* convo_resume callback: turn a replayed message part into a transcript block. */
+static void
+replay_block(void *ctx, enum convo_part part, const char *text, size_t len)
+{
+ struct tui *t = ctx;
+ enum bkind k;
+ size_t bi;
+
+ switch (part) {
+ case CP_USER:
+ k = BK_USER;
+ break;
+ case CP_TOOL:
+ k = BK_TOOL;
+ break;
+ case CP_INFO:
+ k = BK_INFO;
+ break;
+ default:
+ k = BK_ASSIST;
+ break;
+ }
+ bi = block_new(t, k);
+ buf_append(&t->blocks[bi].text, text, len);
+}
+
+static const char *
+prefix_of(enum bkind k)
+{
+ switch (k) {
+ case BK_USER: return ("you ");
+ case BK_ASSIST: return ("fugu ");
+ case BK_TOOL: return (" tool: ");
+ case BK_ERROR: return ("error: ");
+ default: return ("");
+ }
+}
+
+static int
+attr_of(enum bkind k)
+{
+ switch (k) {
+ case BK_USER: return (A_BOLD);
+ case BK_TOOL: return (A_DIM);
+ case BK_ERROR: return (A_BOLD);
+ case BK_INFO: return (A_DIM);
+ default: return (A_NORMAL);
+ }
+}
+
+/* Map mdrender's MD_* style flags to curses attributes (no colour available). */
+static int
+md_attr(int flags)
+{
+ int a = 0;
+
+ if (flags & MD_CODEBLOCK)
+ a |= A_DIM;
+ if (flags & MD_CODE)
+ a |= A_DIM;
+ if (flags & (MD_BOLD | MD_HEADING))
+ a |= A_BOLD;
+ if (flags & MD_ITALIC)
+ a |= A_UNDERLINE;
+ return (a);
+}
+
+/*
+ * Build a block's display as parallel wide-char + per-char-attribute arrays:
+ * the kind prefix (in the block's base attribute) followed by the body. An
+ * assistant block's body is markdown-rendered (styled spans, markers removed);
+ * every other kind keeps its single base attribute. Caller frees the arrays.
+ */
+static size_t
+block_display(struct tui *t, struct block *b, wchar_t **wcp, int **atp)
+{
+ const char *pfx = prefix_of(b->kind);
+ wchar_t *bw, *wc;
+ int base = attr_of(b->kind), *at;
+ size_t pn = strlen(pfx), bn, i, n;
+
+ bw = utf8_to_wcs(b->text.data ? b->text.data : "", b->text.len, &bn);
+ if (t->ascii) { /* transliterate to ASCII first */
+ size_t fn;
+ wchar_t *f = fold_wcs(bw, bn, &fn);
+
+ free(bw);
+ bw = f;
+ bn = fn;
+ }
+
+ if (b->kind == BK_ASSIST) {
+ wchar_t *mw;
+ int *ms;
+ size_t mn = md_render(bw, bn, t->ascii, &mw, &ms);
+
+ free(bw);
+ n = pn + mn;
+ wc = xreallocarray(NULL, n + 1, sizeof(*wc));
+ at = xreallocarray(NULL, n + 1, sizeof(*at));
+ for (i = 0; i < pn; i++) {
+ wc[i] = (unsigned char)pfx[i];
+ at[i] = base;
+ }
+ for (i = 0; i < mn; i++) {
+ wc[pn + i] = mw[i];
+ at[pn + i] = base | md_attr(ms[i]);
+ }
+ free(mw);
+ free(ms);
+ } else {
+ n = pn + bn;
+ wc = xreallocarray(NULL, n + 1, sizeof(*wc));
+ at = xreallocarray(NULL, n + 1, sizeof(*at));
+ for (i = 0; i < pn; i++) {
+ wc[i] = (unsigned char)pfx[i];
+ at[i] = base;
+ }
+ for (i = 0; i < bn; i++) {
+ wc[pn + i] = bw[i];
+ at[pn + i] = base;
+ }
+ free(bw);
+ }
+ *wcp = wc;
+ *atp = at;
+ return (n);
+}
+
+/* Display width of a wide char, treating control/unknown as 1. */
+static int
+cw(wchar_t c)
+{
+ int w = wcwidth(c);
+
+ return (w < 0 ? 1 : w);
+}
+
+/* An ASCII stand-in for a common non-ASCII char, or NULL if there is none. */
+static const char *
+ascii_rep(wchar_t c)
+{
+ switch (c) {
+ case 0x2018: case 0x2019: case 0x201a: case 0x2032:
+ return ("'"); /* single quotes / prime */
+ case 0x201c: case 0x201d: case 0x201e: case 0x2033:
+ return ("\""); /* double quotes */
+ case 0x2013: case 0x2212:
+ return ("-"); /* en dash, minus */
+ case 0x2014: case 0x2015:
+ return ("--"); /* em dash, horizontal bar */
+ case 0x2026:
+ return ("..."); /* ellipsis */
+ case 0x2022: case 0x00b7: case 0x25cf: case 0x2023:
+ return ("*"); /* bullets */
+ case 0x2192: return ("->");
+ case 0x2190: return ("<-");
+ case 0x21d2: return ("=>");
+ case 0x00a0: return (" "); /* no-break space */
+ case 0x00ab: return ("<<");
+ case 0x00bb: return (">>");
+ case 0x00a9: return ("(c)");
+ case 0x00ae: return ("(r)");
+ case 0x2122: return ("(tm)");
+ case 0x00d7: return ("x");
+ case 0x2264: return ("<=");
+ case 0x2265: return (">=");
+ case 0x2260: return ("!=");
+ case 0x2713: case 0x2714: return ("[x]");
+ default: return (NULL);
+ }
+}
+
+/*
+ * Transliterate a wide string to ASCII (lossy, display-only): known punctuation
+ * gets an ASCII stand-in, printable ASCII passes through, everything else
+ * becomes '?'. Worst-case expansion is 4x ("(tm)").
+ */
+static wchar_t *
+fold_wcs(const wchar_t *w, size_t n, size_t *outn)
+{
+ wchar_t *o;
+ size_t i, j = 0;
+
+ o = xreallocarray(NULL, n * 4 + 1, sizeof(*o));
+ for (i = 0; i < n; i++) {
+ const char *r = ascii_rep(w[i]);
+
+ if (r != NULL)
+ while (*r != '\0')
+ o[j++] = (unsigned char)*r++;
+ else if (w[i] == L'\n' || w[i] == L'\t' ||
+ (w[i] >= 32 && w[i] < 127))
+ o[j++] = w[i];
+ else
+ o[j++] = L'?';
+ }
+ *outn = j;
+ return (o);
+}
+
+/* Decode a UTF-8 byte run into a freshly allocated wchar_t array. */
+static wchar_t *
+utf8_to_wcs(const char *s, size_t n, size_t *outlen)
+{
+ wchar_t *w;
+ mbstate_t st;
+ size_t i = 0, o = 0;
+
+ memset(&st, 0, sizeof(st));
+ w = xreallocarray(NULL, n + 1, sizeof(*w));
+ while (i < n) {
+ wchar_t wc;
+ size_t r = mbrtowc(&wc, s + i, n - i, &st);
+
+ if (r == (size_t)-1 || r == (size_t)-2) {
+ wc = 0xfffd; /* invalid byte(s) */
+ r = 1;
+ memset(&st, 0, sizeof(st));
+ } else if (r == 0)
+ r = 1; /* embedded NUL */
+ w[o++] = wc;
+ i += r;
+ }
+ *outlen = o;
+ return (w);
+}
+
+/*
+ * Wrap every block to t->cols display columns and, when draw_it is set, render
+ * the visual lines in [vfrom, vto) into stdscr at row (index - vfrom). Returns
+ * the total visual line count (a blank line separates blocks).
+ */
+static int
+render(struct tui *t, int draw_it, int vfrom, int vto)
+{
+ size_t bi;
+ int g = 0, w = t->cols > 0 ? t->cols : 1;
+
+ for (bi = 0; bi < t->nblocks; bi++) {
+ struct block *b = &t->blocks[bi];
+ wchar_t *wc;
+ int *at;
+ size_t o, i;
+
+ o = block_display(t, b, &wc, &at);
+
+ for (i = 0; i < o; ) {
+ size_t ls = i, lastsp = (size_t)-1, end;
+ int col = 0;
+
+ while (i < o && wc[i] != L'\n') {
+ int width = cw(wc[i]);
+
+ if (col + width > w)
+ break;
+ if (wc[i] == L' ')
+ lastsp = i;
+ col += width;
+ i++;
+ }
+ if (i < o && wc[i] == L'\n') {
+ end = i;
+ i++;
+ } else if (i < o && lastsp != (size_t)-1 &&
+ lastsp > ls) {
+ end = lastsp;
+ i = lastsp + 1;
+ } else
+ end = i;
+ if (end == ls && i < o) { /* a too-wide glyph */
+ end = ls + 1;
+ i = ls + 1;
+ }
+
+ if (draw_it && g >= vfrom && g < vto) {
+ size_t r = ls;
+ int x = 0;
+
+ while (r < end) { /* draw runs of equal attr */
+ size_t e = r, k;
+ int a = at[r];
+
+ while (e < end && at[e] == a)
+ e++;
+ if (a)
+ attron(a);
+ mvaddnwstr(g - vfrom, x, wc + r,
+ (int)(e - r));
+ if (a)
+ attroff(a);
+ for (k = r; k < e; k++)
+ x += cw(wc[k]);
+ r = e;
+ }
+ }
+ g++;
+ }
+ free(wc);
+ free(at);
+ /*
+ * Blank line between blocks, except keep a run of consecutive
+ * tool calls packed line-to-line so a burst reads as one list.
+ */
+ if (!(b->kind == BK_TOOL && bi + 1 < t->nblocks &&
+ t->blocks[bi + 1].kind == BK_TOOL))
+ g++;
+ }
+ return (g);
+}
+
+static void
+draw(struct tui *t)
+{
+ int h, total, maxscroll, prow, i, avail, ccol;
+ size_t k, start;
+ const char *prompt;
+
+ if (t->picking) { /* the model picker owns the whole screen */
+ draw_picker(t);
+ return;
+ }
+
+ erase();
+ h = t->rows - 2;
+ if (h < 1)
+ h = 1;
+
+ total = render(t, 0, 0, 0);
+ maxscroll = total - h;
+ if (maxscroll < 0)
+ maxscroll = 0;
+ if (t->follow)
+ t->scroll = maxscroll;
+ if (t->scroll > maxscroll)
+ t->scroll = maxscroll;
+ if (t->scroll < 0)
+ t->scroll = 0;
+ render(t, 1, t->scroll, t->scroll + h);
+
+ /* status bar */
+ attron(A_REVERSE);
+ for (i = 0; i < t->cols; i++)
+ mvaddch(t->rows - 2, i, ' ');
+ mvaddnstr(t->rows - 2, 1, t->busy ?
+ (t->compacting ? "summarizing..." : "working...") : t->status,
+ t->cols - 2);
+ {
+ const char *mode = t->imode == IM_NORMAL ?
+ "[NORMAL]" : "[INSERT]";
+ int mx = t->cols - (int)strlen(mode) - 1;
+
+ if (mx > 0)
+ mvaddnstr(t->rows - 2, mx, mode, (int)strlen(mode));
+ if (t->scroll < maxscroll) {
+ const char *more = "(scrolled - J/PgDn for latest)";
+ int c = mx - (int)strlen(more) - 1;
+
+ if (c > 0)
+ mvaddnstr(t->rows - 2, c, more,
+ (int)strlen(more));
+ }
+ }
+ attroff(A_REVERSE);
+
+ /* input line: horizontal scroll (by display columns) to show the cursor */
+ prow = t->rows - 1;
+ move(prow, 0);
+ clrtoeol();
+ prompt = t->busy ? "... " : "> ";
+ mvaddstr(prow, 0, prompt);
+ avail = t->cols - (int)strlen(prompt) - 1;
+ if (avail < 1)
+ avail = 1;
+ {
+ wchar_t *din = t->in;
+ size_t dlen = t->ilen, dcur = t->icur;
+ int x, wsum;
+
+ if (t->ascii) { /* fold the input line for display */
+ size_t cn;
+ wchar_t *pre = fold_wcs(t->in, t->icur, &cn);
+
+ free(pre);
+ din = fold_wcs(t->in, t->ilen, &dlen);
+ dcur = cn;
+ }
+
+ start = 0;
+ for (;;) { /* shrink the window from the left */
+ wsum = 0;
+ for (k = start; k < dcur; k++)
+ wsum += cw(din[k]);
+ if (wsum <= avail || start >= dcur)
+ break;
+ start++;
+ }
+ ccol = (int)strlen(prompt);
+ x = ccol;
+ wsum = 0;
+ for (k = start; k < dlen; k++) {
+ int width = cw(din[k]);
+
+ if (wsum + width > avail)
+ break;
+ mvaddnwstr(prow, x, &din[k], 1);
+ x += width;
+ wsum += width;
+ if (k < dcur)
+ ccol += width;
+ }
+ if (t->ascii)
+ free(din);
+ }
+ move(prow, ccol);
+
+ refresh();
+}
+
+static void
+do_resize(struct tui *t)
+{
+ struct winsize ws;
+
+ /* resize_term(), not resizeterm(): the latter ungetch()es a KEY_RESIZE
+ * that our get_wch() loop would feed back here, spinning forever. */
+ if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0)
+ resize_term(ws.ws_row, ws.ws_col);
+ getmaxyx(stdscr, t->rows, t->cols);
+ clearok(stdscr, TRUE);
+}
+
+static int
+drain_imsg(struct tui *t)
+{
+ struct fugu_msg m;
+ int r;
+
+ if (ip_read(t->ip) <= 0)
+ return (-1);
+ for (;;) {
+ r = ip_get(t->ip, &m);
+ if (r == -1)
+ return (-1);
+ if (r == 0)
+ return (0);
+ handle_msg(t, &m);
+ ip_msg_free(&m);
+ }
+}
+
+static void
+handle_msg(struct tui *t, struct fugu_msg *m)
+{
+ switch (m->type) {
+ case M_DELTA: {
+ size_t bi;
+
+ if (t->nblocks > 0 &&
+ t->blocks[t->nblocks - 1].kind == BK_ASSIST)
+ bi = t->nblocks - 1;
+ else
+ bi = block_new(t, BK_ASSIST);
+ buf_append(&t->blocks[bi].text, m->data, m->len);
+ t->follow = 1;
+ break;
+ }
+ case M_MESSAGE: /* a full conversation message to keep */
+ convo_add_raw(t->cv, (char *)m->data, m->len);
+ journal_append(t->j, (char *)m->data, m->len);
+ break;
+ case M_TOOL_CALL: {
+ struct json_doc d;
+ size_t bi;
+ char *name = NULL, *input = NULL;
+ int nt, it;
+
+ bi = block_new(t, BK_TOOL);
+ if (json_parse(&d, m->data, m->len) == 0 && d.ntok >= 1) {
+ if ((nt = json_obj_get(&d, 0, "name")) >= 0)
+ name = json_tok_strdup(&d, nt);
+ if ((it = json_obj_get(&d, 0, "input")) >= 0)
+ input = json_tok_strdup(&d, it);
+ }
+ buf_printf(&t->blocks[bi].text, "%s %s",
+ name != NULL ? name : "(tool)",
+ input != NULL ? input : "");
+ free(name);
+ free(input);
+ json_doc_free(&d);
+ t->follow = 1;
+ break;
+ }
+ case M_TOOL_REQUEST: {
+ /*
+ * Model-invoked `skill` tool call routed back to us by net's
+ * tool_dst(). Look the skill up in t->sk and reply with the
+ * body as a tool_result so net can splice it into the next
+ * Messages API turn. On a send failure we're shutting down --
+ * mark the in-flight turn failed so its already-journaled
+ * records (user msg / skill synth trio / partial assistant)
+ * don't replay as orphans on resume.
+ */
+ struct buf out;
+
+ buf_init(&out);
+ skills_handle_tool_request(t->sk, m->data, m->len, &out);
+ if (ip_compose(t->ip, M_TOOL_RESULT, EP_NET, EP_UI, out.data,
+ out.len) == -1 || ip_flush(t->ip) == -1) {
+ log_warnx("tool result send failed");
+ convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+ convo_truncate(t->cv, t->cv_mark);
+ t->quit = 1;
+ }
+ buf_free(&out);
+ break;
+ }
+ case M_USAGE: {
+ struct json_doc d;
+ char *in = NULL, *out = NULL;
+ int ti, to;
+
+ if (json_parse(&d, m->data, m->len) == 0 && d.ntok >= 1) {
+ if ((ti = json_obj_get(&d, 0, "input_tokens")) >= 0)
+ in = json_tok_strdup(&d, ti);
+ if ((to = json_obj_get(&d, 0, "output_tokens")) >= 0)
+ out = json_tok_strdup(&d, to);
+ }
+ if (in != NULL && out != NULL)
+ snprintf(t->status, sizeof(t->status),
+ "ready (tokens: %s in / %s out)", in, out);
+ free(in);
+ free(out);
+ json_doc_free(&d);
+ break;
+ }
+ case M_TURN_DONE:
+ t->busy = 0;
+ break;
+ case M_SUMMARY: { /* /compact: the summary arrived */
+ struct buf mk;
+ char *s = xmalloc(m->len + 1);
+ size_t bi;
+
+ memcpy(s, m->data, m->len);
+ s[m->len] = '\0';
+
+ buf_init(&mk);
+ convo_marker_compact(&mk, s);
+ journal_append(t->j, mk.data, mk.len);
+ buf_free(&mk);
+ convo_set_summary(t->cv, s);
+
+ bi = block_new(t, BK_INFO);
+ buf_puts_cstr(&t->blocks[bi].text, "earlier conversation compacted");
+ bi = block_new(t, BK_ASSIST);
+ buf_append(&t->blocks[bi].text, s, m->len);
+ free(s);
+
+ t->busy = 0;
+ t->compacting = 0;
+ t->follow = 1;
+ break;
+ }
+ case M_ERROR: {
+ size_t bi = block_new(t, BK_ERROR);
+
+ buf_append(&t->blocks[bi].text, m->data, m->len);
+ /*
+ * Roll back ONLY when a turn was actually in flight (busy).
+ * /model failures arrive here with busy=false and a cv_mark
+ * left over from an EARLIER successful turn -- truncating to
+ * that stale mark would destroy the prior turn in memory, and
+ * journaling turn_failed would drop it on resume too.
+ * convo_journal_turn_failed runs BEFORE convo_truncate so the
+ * helper sees cv->n > mark and emits; afterwards cv->n == mark
+ * and a second M_ERROR (e.g. queued, retried) is a clean no-op.
+ */
+ if (t->busy) {
+ convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+ convo_truncate(t->cv, t->cv_mark);
+ }
+ t->busy = 0;
+ t->compacting = 0;
+ t->loading_models = 0; /* a /model fetch failure resolves here too */
+ t->follow = 1;
+ break;
+ }
+ case M_MODELS: /* reply to /model: the id list */
+ t->loading_models = 0;
+ picker_open(t, (char *)m->data, m->len);
+ break;
+ default:
+ break;
+ }
+}
+
+/*
+ * The editor is modal (vi): it starts in insert mode and Esc switches to
+ * normal mode. A handful of keys mean the same thing in both modes -- the
+ * keypad navigation keys, the terminal controls (Ctrl-L/D/C), transcript
+ * paging, and J/K line-scrolling -- and are handled here; everything else is
+ * dispatched to insert_key() or normal_key() by the current mode. Scrolling
+ * works even while a turn is in flight; editing does not.
+ */
+static void
+handle_input(struct tui *t, wint_t wch, int iskey)
+{
+ int page = t->rows - 4 > 1 ? t->rows - 4 : 1;
+
+ /* The model picker is modal: while it is up, it owns every keystroke. */
+ if (t->picking) {
+ picker_key(t, wch, iskey);
+ return;
+ }
+
+ /* Any key but Tab/Shift-Tab ends an in-progress completion cycle. */
+ if (!((!iskey && wch == L'\t') || (iskey && wch == KEY_BTAB)))
+ t->comping = 0;
+
+ /*
+ * A pending d-operator (set by 'd' in normal mode) is consumed by
+ * exactly the next key: a plain motion key applies the deletion, while
+ * any keypad/scroll/control key cancels it. Resolving it here -- before
+ * the keypad and scroll keys that return early below -- keeps a stray
+ * 'd' from silently turning a later keystroke into a deletion.
+ */
+ if (t->pend == L'd') {
+ t->pend = 0;
+ if (!iskey && !t->busy) {
+ do_dmotion(t, wch);
+ return;
+ }
+ /* fall through: the operator is cancelled, key handled normally */
+ }
+
+ if (iskey) {
+ switch (wch) {
+ case KEY_PPAGE:
+ t->follow = 0;
+ t->scroll -= page;
+ if (t->scroll < 0)
+ t->scroll = 0;
+ return;
+ case KEY_NPAGE:
+ t->scroll += page;
+ t->follow = 1;
+ return;
+ case KEY_RESIZE:
+ do_resize(t);
+ return;
+ case KEY_UP: /* recall an earlier prompt */
+ if (!t->busy)
+ hist_browse(t, -1);
+ return;
+ case KEY_DOWN: /* and back toward the live line */
+ if (!t->busy)
+ hist_browse(t, +1);
+ return;
+ case KEY_LEFT:
+ if (!t->busy && t->icur > 0)
+ t->icur--;
+ return;
+ case KEY_RIGHT:
+ if (!t->busy && t->icur < t->ilen)
+ t->icur++;
+ return;
+ case KEY_HOME:
+ if (!t->busy)
+ t->icur = 0;
+ return;
+ case KEY_END:
+ if (!t->busy)
+ t->icur = t->ilen;
+ return;
+ case KEY_BTAB: /* Shift-Tab: reverse the completion cycle */
+ if (!t->busy && t->ilen > 0 && t->in[0] == L'/')
+ complete_tab(t, -1);
+ return;
+ case KEY_BACKSPACE:
+ if (t->busy)
+ return;
+ if (t->imode == IM_INSERT)
+ in_backspace(t);
+ else if (t->icur > 0)
+ t->icur--; /* normal mode: move left */
+ return;
+ case KEY_DC:
+ if (!t->busy)
+ in_delch(t);
+ return;
+ default:
+ return;
+ }
+ }
+
+ switch (wch) { /* terminal controls: any mode, even busy */
+ case 27: /* Esc: enter vi normal mode */
+ if (t->imode == IM_INSERT) {
+ t->imode = IM_NORMAL;
+ if (t->icur > 0) /* vi: leaving insert steps left */
+ t->icur--;
+ }
+ return; /* allowed mid-turn so J/K can scroll */
+ case 12: /* Ctrl-L: repaint */
+ clearok(stdscr, TRUE);
+ return;
+ case 4: /* Ctrl-D: quit on an empty line */
+ if (t->ilen == 0)
+ t->quit = 1;
+ return;
+ case 3: /* Ctrl-C: clear the line, else quit */
+ if (t->ilen > 0) {
+ hist_reset(t);
+ t->ilen = 0;
+ t->icur = 0;
+ t->imode = IM_INSERT;
+ } else
+ t->quit = 1;
+ return;
+ }
+
+ /* J/K scroll the transcript in normal mode, even mid-turn. */
+ if (t->imode == IM_NORMAL) {
+ if (wch == L'K') {
+ t->follow = 0;
+ if (t->scroll > 0)
+ t->scroll--;
+ return;
+ }
+ if (wch == L'J') {
+ int m = max_scroll(t);
+
+ if (t->scroll < m)
+ t->scroll++;
+ t->follow = (t->scroll >= m);
+ return;
+ }
+ }
+
+ if (t->busy)
+ return; /* no editing while a turn is in flight */
+
+ /* Tab cycles slash-command completions on a "/..." line (either mode). */
+ if (!iskey && wch == L'\t' && t->ilen > 0 && t->in[0] == L'/') {
+ complete_tab(t, 1);
+ return;
+ }
+
+ if (t->imode == IM_INSERT)
+ insert_key(t, wch);
+ else
+ normal_key(t, wch);
+}
+
+/* Plain (non-keypad) keys in insert mode (Esc is handled in handle_input). */
+static void
+insert_key(struct tui *t, wint_t wch)
+{
+ switch (wch) {
+ case L'\r':
+ case L'\n':
+ submit(t);
+ return;
+ case 8:
+ case 127: /* Backspace (terminals that send a byte) */
+ in_backspace(t);
+ return;
+ default:
+ if (wch >= 32 && wch != 127)
+ in_insert(t, (wchar_t)wch);
+ return;
+ }
+}
+
+/*
+ * Plain (non-keypad) keys in normal mode. J/K scrolling and a pending
+ * d-operator are resolved earlier in handle_input(); a 'd' here only arms one.
+ */
+static void
+normal_key(struct tui *t, wint_t wch)
+{
+ switch (wch) {
+ /* enter insert mode */
+ case L'i':
+ t->imode = IM_INSERT;
+ return;
+ case L'I':
+ t->icur = first_nonblank(t);
+ t->imode = IM_INSERT;
+ return;
+ case L'a':
+ if (t->icur < t->ilen)
+ t->icur++;
+ t->imode = IM_INSERT;
+ return;
+ case L'A':
+ case L'o': /* single line: open-below == append */
+ t->icur = t->ilen;
+ t->imode = IM_INSERT;
+ return;
+ case L'O': /* open-above == insert at first column */
+ t->icur = first_nonblank(t);
+ t->imode = IM_INSERT;
+ return;
+ /* movement */
+ case 8:
+ case 127:
+ case L'h':
+ if (t->icur > 0)
+ t->icur--;
+ return;
+ case L' ':
+ case L'l':
+ if (t->icur < t->ilen)
+ t->icur++;
+ return;
+ case L'0':
+ t->icur = 0;
+ return;
+ case L'$':
+ t->icur = t->ilen;
+ return;
+ case L'^':
+ t->icur = first_nonblank(t);
+ return;
+ case L'w':
+ t->icur = word_fwd(t, t->icur);
+ return;
+ case L'b':
+ t->icur = word_back(t, t->icur);
+ return;
+ case L'e':
+ t->icur = word_end(t, t->icur);
+ return;
+ /* single-line vertical motion recalls prompts instead */
+ case L'k':
+ hist_browse(t, -1);
+ return;
+ case L'j':
+ hist_browse(t, +1);
+ return;
+ /* deletion */
+ case L'x':
+ in_delch(t);
+ return;
+ case L'D':
+ del_range(t, t->icur, t->ilen);
+ return;
+ case L'd':
+ t->pend = L'd';
+ return;
+ /* submit straight from normal mode too */
+ case L'\r':
+ case L'\n':
+ submit(t);
+ return;
+ default:
+ return;
+ }
+}
+
+/* Apply a pending d-operator to the motion key that followed it. */
+static void
+do_dmotion(struct tui *t, wint_t key)
+{
+ size_t to;
+
+ switch (key) {
+ case L'd': /* dd: delete the whole line */
+ del_range(t, 0, t->ilen);
+ return;
+ case L'$':
+ del_range(t, t->icur, t->ilen);
+ return;
+ case L'0':
+ del_range(t, 0, t->icur);
+ return;
+ case L'^':
+ to = first_nonblank(t);
+ if (to < t->icur)
+ del_range(t, to, t->icur);
+ else
+ del_range(t, t->icur, to);
+ return;
+ case L'w':
+ del_range(t, t->icur, word_fwd(t, t->icur));
+ return;
+ case L'b':
+ del_range(t, word_back(t, t->icur), t->icur);
+ return;
+ case L'e':
+ to = word_end(t, t->icur);
+ if (to < t->ilen)
+ to++; /* inclusive of the word's last char */
+ del_range(t, t->icur, to);
+ return;
+ case 8:
+ case 127:
+ case L'h':
+ if (t->icur > 0)
+ del_range(t, t->icur - 1, t->icur);
+ return;
+ case L' ':
+ case L'l':
+ del_range(t, t->icur, t->icur + 1);
+ return;
+ default: /* Esc or anything else cancels */
+ return;
+ }
+}
+
+static void
+in_insert(struct tui *t, wchar_t c)
+{
+ hist_reset(t);
+ if (t->ilen + 1 > t->icap) {
+ t->icap = t->icap ? t->icap * 2 : 64;
+ t->in = xreallocarray(t->in, t->icap, sizeof(wchar_t));
+ }
+ memmove(&t->in[t->icur + 1], &t->in[t->icur],
+ (t->ilen - t->icur) * sizeof(wchar_t));
+ t->in[t->icur] = c;
+ t->ilen++;
+ t->icur++;
+}
+
+/* Replace the input line with a copy of src[0..n) and park the cursor at end. */
+static void
+in_set(struct tui *t, const wchar_t *src, size_t n)
+{
+ if (n + 1 > t->icap) {
+ t->icap = n + 1;
+ t->in = xreallocarray(t->in, t->icap, sizeof(wchar_t));
+ }
+ if (n > 0)
+ memcpy(t->in, src, n * sizeof(wchar_t));
+ t->ilen = n;
+ t->icur = n;
+}
+
+/*
+ * Tab (dir +1) / Shift-Tab (dir -1) on a "/..." input line cycle through the
+ * matching slash commands. The typed text is captured as the base on the first
+ * press; each consecutive press steps one match in dir, wrapping around (the
+ * first forward press lands on the first match, the first reverse press on the
+ * last). The cycle resets as soon as any other key is pressed (see handle_input).
+ */
+static void
+complete_tab(struct tui *t, int dir)
+{
+ const char *matches[16];
+ wchar_t wbuf[64];
+ const char *m;
+ size_t i, k;
+ int n, first = 0;
+
+ if (!t->comping) { /* capture the typed prefix (ASCII) */
+ for (i = 0, k = 0; i < t->ilen && k < sizeof(t->compbase) - 1; i++)
+ t->compbase[k++] = t->in[i] < 128 ? (char)t->in[i] : '?';
+ t->compbase[k] = '\0';
+ t->comping = 1;
+ first = 1;
+ }
+
+ n = ui_complete(t->sk, t->compbase, matches, 16);
+ if (n == 0) {
+ beep(); /* no command matches the prefix */
+ t->comping = 0;
+ return;
+ }
+ if (first)
+ t->compidx = dir > 0 ? 0 : n - 1;
+ else {
+ t->compidx += dir;
+ if (t->compidx < 0)
+ t->compidx = n - 1;
+ else if (t->compidx >= n)
+ t->compidx = 0;
+ }
+ m = matches[t->compidx];
+ for (k = 0; m[k] != '\0' && k < sizeof(wbuf) / sizeof(wbuf[0]); k++)
+ wbuf[k] = (unsigned char)m[k];
+ in_set(t, wbuf, k);
+}
+
+/* Delete the character before the cursor (insert-mode Backspace). */
+static void
+in_backspace(struct tui *t)
+{
+ if (t->icur == 0)
+ return;
+ del_range(t, t->icur - 1, t->icur);
+}
+
+/* Delete the character under the cursor (x / Delete). */
+static void
+in_delch(struct tui *t)
+{
+ if (t->icur < t->ilen)
+ del_range(t, t->icur, t->icur + 1);
+}
+
+/* Delete input[from..to); clamp, leave the cursor at `from`. */
+static void
+del_range(struct tui *t, size_t from, size_t to)
+{
+ if (to > t->ilen)
+ to = t->ilen;
+ if (from >= to)
+ return;
+ hist_reset(t);
+ memmove(&t->in[from], &t->in[to], (t->ilen - to) * sizeof(wchar_t));
+ t->ilen -= to - from;
+ t->icur = from;
+}
+
+/* Index of the first non-blank input character (or ilen if all blank). */
+static size_t
+first_nonblank(struct tui *t)
+{
+ size_t i = 0;
+
+ while (i < t->ilen && (t->in[i] == L' ' || t->in[i] == L'\t'))
+ i++;
+ return (i);
+}
+
+/* vi word class: 0 blank, 1 word (alnum/_), 2 punctuation. */
+static int
+wclass(wchar_t c)
+{
+ if (c == L' ' || c == L'\t' || iswspace(c))
+ return (0);
+ if (c == L'_' || iswalnum(c))
+ return (1);
+ return (2);
+}
+
+/* Start of the next word at or after i (the `w` motion). */
+static size_t
+word_fwd(struct tui *t, size_t i)
+{
+ int c;
+
+ if (i >= t->ilen)
+ return (t->ilen);
+ c = wclass(t->in[i]);
+ if (c != 0)
+ while (i < t->ilen && wclass(t->in[i]) == c)
+ i++;
+ while (i < t->ilen && wclass(t->in[i]) == 0)
+ i++;
+ return (i);
+}
+
+/* Start of the word before i (the `b` motion). */
+static size_t
+word_back(struct tui *t, size_t i)
+{
+ int c;
+
+ if (i == 0)
+ return (0);
+ i--;
+ while (i > 0 && wclass(t->in[i]) == 0)
+ i--;
+ c = wclass(t->in[i]);
+ if (c == 0)
+ return (i);
+ while (i > 0 && wclass(t->in[i - 1]) == c)
+ i--;
+ return (i);
+}
+
+/* End of the word at or after i (the `e` motion). */
+static size_t
+word_end(struct tui *t, size_t i)
+{
+ int c;
+
+ if (i >= t->ilen)
+ return (t->ilen ? t->ilen - 1 : 0);
+ i++;
+ while (i < t->ilen && wclass(t->in[i]) == 0)
+ i++;
+ if (i >= t->ilen)
+ return (t->ilen ? t->ilen - 1 : 0);
+ c = wclass(t->in[i]);
+ while (i + 1 < t->ilen && wclass(t->in[i + 1]) == c)
+ i++;
+ return (i);
+}
+
+/* Largest valid scroll offset: total visual lines minus the visible window. */
+static int
+max_scroll(struct tui *t)
+{
+ int h = t->rows - 2, m;
+
+ if (h < 1)
+ h = 1;
+ m = render(t, 0, 0, 0) - h;
+ return (m < 0 ? 0 : m);
+}
+
+/* Stop browsing prompt history: the live line is whatever is in the editor. */
+static void
+hist_reset(struct tui *t)
+{
+ t->histpos = t->nhist;
+}
+
+/* Append the current input line to the prompt history (skipping a repeat). */
+static void
+hist_push(struct tui *t)
+{
+ wchar_t *copy;
+
+ if (t->ilen == 0)
+ return;
+ if (t->nhist > 0 && t->histlen[t->nhist - 1] == t->ilen &&
+ wmemcmp(t->hist[t->nhist - 1], t->in, t->ilen) == 0) {
+ t->histpos = t->nhist;
+ return;
+ }
+ copy = xreallocarray(NULL, t->ilen, sizeof(wchar_t));
+ wmemcpy(copy, t->in, t->ilen);
+ t->hist = xreallocarray(t->hist, t->nhist + 1, sizeof(*t->hist));
+ t->histlen = xreallocarray(t->histlen, t->nhist + 1,
+ sizeof(*t->histlen));
+ t->hist[t->nhist] = copy;
+ t->histlen[t->nhist] = t->ilen;
+ t->nhist++;
+ t->histpos = t->nhist;
+}
+
+/* Move through prompt history: dir < 0 older, dir > 0 newer (toward live). */
+static void
+hist_browse(struct tui *t, int dir)
+{
+ if (dir < 0) {
+ if (t->nhist == 0 || t->histpos == 0)
+ return;
+ if (t->histpos == t->nhist) { /* stash the live line first */
+ free(t->histsave);
+ t->histsave = NULL;
+ t->histsavelen = t->ilen;
+ if (t->ilen > 0) {
+ t->histsave = xreallocarray(NULL, t->ilen,
+ sizeof(wchar_t));
+ wmemcpy(t->histsave, t->in, t->ilen);
+ }
+ }
+ t->histpos--;
+ in_set(t, t->hist[t->histpos], t->histlen[t->histpos]);
+ } else {
+ if (t->histpos >= t->nhist)
+ return;
+ t->histpos++;
+ if (t->histpos == t->nhist)
+ in_set(t, t->histsave, t->histsavelen);
+ else
+ in_set(t, t->hist[t->histpos], t->histlen[t->histpos]);
+ }
+}
+
+static void
+hist_free(struct tui *t)
+{
+ size_t i;
+
+ for (i = 0; i < t->nhist; i++)
+ free(t->hist[i]);
+ free(t->hist);
+ free(t->histlen);
+ free(t->histsave);
+ t->hist = NULL;
+ t->histlen = NULL;
+ t->histsave = NULL;
+ t->nhist = 0;
+ t->histpos = 0;
+}
+
+static void
+submit(struct tui *t)
+{
+ struct buf msgs, text;
+ mbstate_t st;
+ size_t bi, i;
+ char mb[MB_LEN_MAX];
+ enum ui_cmd cmd;
+
+ if (t->ilen == 0)
+ return;
+ if (t->loading_models)
+ return; /* a /model fetch is in flight; the picker is next */
+
+ hist_push(t); /* remember the line for j/k recall */
+
+ /* Encode the wide input line back to UTF-8 for the conversation. */
+ buf_init(&text);
+ memset(&st, 0, sizeof(st));
+ for (i = 0; i < t->ilen; i++) {
+ size_t r = wcrtomb(mb, t->in[i], &st);
+
+ if (r != (size_t)-1)
+ buf_append(&text, mb, r);
+ }
+ buf_terminate(&text);
+
+ /* A leading-slash command is intercepted, never sent as a message. */
+ cmd = ui_classify(text.data);
+ if (cmd != UC_NONE) {
+ do_command(t, cmd);
+ t->ilen = 0;
+ t->icur = 0;
+ t->imode = IM_INSERT;
+ buf_free(&text);
+ return;
+ }
+
+ /*
+ * Not a built-in, but a slash-prefixed token may name a loaded skill:
+ * synthesise an assistant tool_use + user tool_result pair so the model
+ * receives the body as authoritative retrieved instructions, record a
+ * journal marker, and submit as a normal turn.
+ */
+ {
+ const struct skill *match = NULL;
+ const char *trailing = NULL;
+
+ if (text.data[0] == '/' &&
+ (match = skills_match(t->sk, text.data, &trailing)) != NULL) {
+ struct buf mk, up, am, um;
+ char id[40];
+
+ buf_init(&mk);
+ skills_marker(&mk, match->name);
+ journal_append(t->j, mk.data, mk.len);
+ buf_free(&mk);
+
+ buf_init(&up);
+ buf_init(&am);
+ buf_init(&um);
+ skills_synth_invocation(match, text.data, trailing,
+ &up, &am, &um, id, sizeof(id));
+
+ /* Echo the invocation in the transcript, not the body. */
+ bi = block_new(t, BK_USER);
+ buf_append(&t->blocks[bi].text, text.data, text.len);
+ t->cv_mark = t->cv->n;
+ convo_add_raw(t->cv, up.data, up.len);
+ convo_add_raw(t->cv, am.data, am.len);
+ convo_add_raw(t->cv, um.data, um.len);
+ journal_append(t->j, up.data, up.len);
+ journal_append(t->j, am.data, am.len);
+ journal_append(t->j, um.data, um.len);
+ buf_free(&up);
+ buf_free(&am);
+ buf_free(&um);
+ buf_free(&text);
+ goto submit;
+ }
+ }
+
+ bi = block_new(t, BK_USER);
+ buf_append(&t->blocks[bi].text, text.data, text.len);
+ t->cv_mark = t->cv->n;
+ convo_add_user(t->cv, text.data);
+ journal_append(t->j, t->cv->msg[t->cv->n - 1],
+ strlen(t->cv->msg[t->cv->n - 1]));
+ buf_free(&text);
+
+submit:
+ t->ilen = 0;
+ t->icur = 0;
+ t->imode = IM_INSERT;
+
+ buf_init(&msgs);
+ convo_build(t->cv, &msgs);
+ if (ip_compose(t->ip, M_SUBMIT, EP_NET, EP_UI, msgs.data,
+ msgs.len) == -1 || ip_flush(t->ip) == -1) {
+ /*
+ * Submit failed to leave the box; cv + journal already hold
+ * this turn's user message (or skill synth trio). Mark the
+ * turn failed and roll back before quitting so resume drops
+ * the orphan.
+ */
+ convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+ convo_truncate(t->cv, t->cv_mark);
+ t->quit = 1;
+ }
+ buf_free(&msgs);
+
+ t->busy = 1;
+ t->follow = 1;
+}
+
+/* Run a slash command (typed at the input line) instead of sending a message. */
+static void
+do_command(struct tui *t, enum ui_cmd cmd)
+{
+ struct buf mk, msgs;
+ size_t bi;
+
+ switch (cmd) {
+ case UC_QUIT:
+ t->quit = 1;
+ return;
+ case UC_CLEAR:
+ buf_init(&mk);
+ convo_marker_clear(&mk);
+ journal_append(t->j, mk.data, mk.len);
+ buf_free(&mk);
+ convo_clear(t->cv);
+ bi = block_new(t, BK_INFO);
+ buf_puts_cstr(&t->blocks[bi].text, "context cleared");
+ t->cv_mark = t->cv->n;
+ t->follow = 1;
+ return;
+ case UC_COMPACT:
+ if (t->cv->n == 0) {
+ bi = block_new(t, BK_INFO);
+ buf_puts_cstr(&t->blocks[bi].text, "nothing to compact");
+ t->follow = 1;
+ return;
+ }
+ buf_init(&msgs);
+ convo_build(t->cv, &msgs);
+ if (ip_compose(t->ip, M_SUMMARIZE, EP_NET, EP_UI, msgs.data,
+ msgs.len) == -1 || ip_flush(t->ip) == -1)
+ t->quit = 1;
+ buf_free(&msgs);
+ t->cv_mark = t->cv->n; /* a later M_ERROR truncate is a no-op */
+ t->busy = 1;
+ t->compacting = 1;
+ t->follow = 1;
+ return;
+ case UC_MODELS:
+ request_models(t);
+ return;
+ default:
+ return;
+ }
+}
+
+/* ---- model picker (/model): a modal, fuzzy-searchable overlay ---- */
+
+/* Ask the net worker for the model list; its M_MODELS reply opens the picker. */
+static void
+request_models(struct tui *t)
+{
+ size_t bi;
+
+ if (t->loading_models)
+ return;
+ if (ip_compose(t->ip, M_MODELS, EP_NET, EP_UI, NULL, 0) == -1 ||
+ ip_flush(t->ip) == -1) {
+ t->quit = 1;
+ return;
+ }
+ t->loading_models = 1;
+ bi = block_new(t, BK_INFO);
+ buf_puts_cstr(&t->blocks[bi].text, "fetching models...");
+ t->follow = 1;
+}
+
+static void
+picker_free_models(struct tui *t)
+{
+ size_t i;
+
+ for (i = 0; i < t->nmodels; i++)
+ free(t->models[i]);
+ free(t->models);
+ t->models = NULL;
+ t->nmodels = 0;
+}
+
+/* Open the picker over a newline-separated id list (the M_MODELS payload). */
+static void
+picker_open(struct tui *t, char *list, size_t len)
+{
+ size_t start, i;
+
+ picker_free_models(t);
+ for (start = 0, i = 0; i <= len; i++) {
+ if (i == len || list[i] == '\n') {
+ if (i > start) {
+ char *s = xmalloc(i - start + 1);
+
+ memcpy(s, list + start, i - start);
+ s[i - start] = '\0';
+ t->models = xreallocarray(t->models,
+ t->nmodels + 1, sizeof(*t->models));
+ t->models[t->nmodels++] = s;
+ }
+ start = i + 1;
+ }
+ }
+ if (t->nmodels == 0) {
+ size_t bi = block_new(t, BK_INFO);
+
+ buf_puts_cstr(&t->blocks[bi].text, "no models available");
+ t->follow = 1;
+ return;
+ }
+ t->pquery[0] = '\0';
+ t->pqlen = 0;
+ picker_refilter(t);
+ t->picking = 1;
+}
+
+static void
+picker_close(struct tui *t)
+{
+ t->picking = 0;
+ picker_free_models(t);
+ free(t->pmatch);
+ t->pmatch = NULL;
+ t->npmatch = 0;
+ t->pqlen = 0;
+ t->pquery[0] = '\0';
+ clearok(stdscr, TRUE); /* fully repaint over the overlay next frame */
+}
+
+struct pscore {
+ int idx;
+ int score;
+};
+
+static int
+pscore_cmp(const void *a, const void *b)
+{
+ const struct pscore *pa = a, *pb = b;
+
+ if (pa->score != pb->score)
+ return (pb->score - pa->score); /* higher score first */
+ return (pa->idx - pb->idx); /* ties keep the provider's order */
+}
+
+/* Recompute the ranked list of models matching the current filter. */
+static void
+picker_refilter(struct tui *t)
+{
+ struct pscore *sc;
+ size_t i, n = 0;
+ size_t cap = t->nmodels > 0 ? t->nmodels : 1;
+
+ t->pmatch = xreallocarray(t->pmatch, cap, sizeof(*t->pmatch));
+ sc = xreallocarray(NULL, cap, sizeof(*sc));
+ for (i = 0; i < t->nmodels; i++) {
+ int score;
+
+ if (fuzzy_match(t->pquery, t->models[i], &score)) {
+ sc[n].idx = (int)i;
+ sc[n].score = score;
+ n++;
+ }
+ }
+ qsort(sc, n, sizeof(*sc), pscore_cmp);
+ for (i = 0; i < n; i++)
+ t->pmatch[i] = sc[i].idx;
+ free(sc);
+ t->npmatch = n;
+ t->psel = 0;
+ t->ptop = 0;
+}
+
+/*
+ * A model row for display. When several providers are configured net tags each
+ * id "name\tmodel"; show that as "name: model". Bare ids (one provider) pass
+ * through unchanged.
+ */
+static void
+model_label(const char *raw, char *out, size_t cap)
+{
+ const char *tab = strchr(raw, '\t');
+
+ if (tab == NULL)
+ snprintf(out, cap, "%s", raw);
+ else
+ snprintf(out, cap, "%.*s: %s", (int)(tab - raw), raw, tab + 1);
+}
+
+/* One keystroke while the picker is up (it owns all input -- see handle_input). */
+static void
+picker_key(struct tui *t, wint_t wch, int iskey)
+{
+ if (iskey) {
+ switch (wch) {
+ case KEY_UP:
+ if (t->psel > 0)
+ t->psel--;
+ return;
+ case KEY_DOWN:
+ if ((size_t)t->psel + 1 < t->npmatch)
+ t->psel++;
+ return;
+ case KEY_BACKSPACE:
+ if (t->pqlen > 0) {
+ t->pquery[--t->pqlen] = '\0';
+ picker_refilter(t);
+ }
+ return;
+ default:
+ return;
+ }
+ }
+ switch (wch) {
+ case 27: /* Esc / Ctrl-G: cancel */
+ case 7:
+ picker_close(t);
+ return;
+ case L'\r':
+ case L'\n': /* Enter: choose the highlighted model */
+ if (t->npmatch > 0) {
+ const char *m = t->models[t->pmatch[t->psel]];
+ char lbl[256];
+ size_t bi;
+
+ if (ip_compose(t->ip, M_SET_MODEL, EP_NET, EP_UI, m,
+ strlen(m)) == -1 || ip_flush(t->ip) == -1)
+ t->quit = 1;
+ bi = block_new(t, BK_INFO);
+ model_label(m, lbl, sizeof(lbl));
+ buf_printf(&t->blocks[bi].text, "model set to %s", lbl);
+ t->follow = 1;
+ }
+ picker_close(t);
+ return;
+ case 16: /* Ctrl-P: up */
+ if (t->psel > 0)
+ t->psel--;
+ return;
+ case 14: /* Ctrl-N: down */
+ if ((size_t)t->psel + 1 < t->npmatch)
+ t->psel++;
+ return;
+ case 8:
+ case 127: /* Backspace */
+ if (t->pqlen > 0) {
+ t->pquery[--t->pqlen] = '\0';
+ picker_refilter(t);
+ }
+ return;
+ default:
+ if (wch >= 32 && wch < 127 &&
+ t->pqlen < sizeof(t->pquery) - 1) {
+ t->pquery[t->pqlen++] = (char)wch;
+ t->pquery[t->pqlen] = '\0';
+ picker_refilter(t);
+ }
+ return;
+ }
+}
+
+/* Render the modal picker over the whole screen. */
+static void
+draw_picker(struct tui *t)
+{
+ int cols = t->cols, rows = t->rows;
+ int listh = rows - 3 > 0 ? rows - 3 : 1;
+ int i, row, fx;
+ char st[160];
+
+ erase();
+ attron(A_BOLD);
+ mvaddnstr(0, 0, "Select a model", cols - 1);
+ attroff(A_BOLD);
+ mvaddnstr(1, 0, "filter: ", cols - 1);
+ if (t->pqlen > 0)
+ mvaddnstr(1, 8, t->pquery, cols - 9 > 0 ? cols - 9 : 0);
+
+ if (t->psel < t->ptop)
+ t->ptop = t->psel;
+ if (t->psel >= t->ptop + listh)
+ t->ptop = t->psel - listh + 1;
+ if (t->ptop < 0)
+ t->ptop = 0;
+
+ if (t->npmatch == 0)
+ mvaddnstr(2, 0, "(no matching models)", cols - 1);
+ for (i = 0; i < listh; i++) {
+ char lbl[256];
+ int sel;
+
+ row = t->ptop + i;
+ if ((size_t)row >= t->npmatch)
+ break;
+ model_label(t->models[t->pmatch[row]], lbl, sizeof(lbl));
+ sel = (row == t->psel);
+ if (sel)
+ attron(A_BOLD);
+ mvaddstr(2 + i, 0, sel ? "> " : " ");
+ mvaddnstr(2 + i, 2, lbl, cols - 3 > 0 ? cols - 3 : 0);
+ if (sel)
+ attroff(A_BOLD);
+ }
+
+ snprintf(st, sizeof(st), "%zu/%zu Up/Down or ^P/^N "
+ "Enter: choose Esc: cancel", t->npmatch, t->nmodels);
+ mvaddnstr(rows - 1, 0, st, cols - 1);
+
+ fx = 8 + (int)t->pqlen;
+ if (fx > cols - 1)
+ fx = cols - 1;
+ move(1, fx);
+ refresh();
+}