Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d90ce66fb2 | ||
|
|
c7a6429bae | ||
|
|
57d0fb0fae | ||
|
|
27a01dd2af | ||
|
|
eb77305580 | ||
|
|
c986df6799 | ||
|
|
687afa0145 | ||
|
|
cad389f62f | ||
|
|
cfe3f24e25 | ||
|
|
a6da93332d | ||
|
|
f21be1aef6 | ||
|
|
3c70b6f367 | ||
|
|
b3b555fa23 | ||
|
|
e9805c7eb6 | ||
|
|
3d5af85713 | ||
|
|
c6341f020a | ||
|
|
e157311cd8 | ||
|
|
a5f502eb9c | ||
|
|
7ed2a7d3b4 | ||
|
|
674858fe5e | ||
|
|
9a37881441 | ||
|
|
f324f04944 | ||
|
|
cdafdf6e0f | ||
|
|
b340f86a45 | ||
|
|
f450a6c84f | ||
|
|
5a5d938e8e | ||
|
|
cc86a74dc5 | ||
|
|
6e449b464c | ||
|
|
868fe28c96 | ||
|
|
6e69698343 | ||
|
|
eb2acfeeb8 | ||
|
|
6e5a9cfce3 | ||
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 | ||
|
|
dc2265da0b | ||
|
|
c176b71b3d | ||
|
|
4e8bdc137e | ||
|
|
42235dcb6a | ||
|
|
05330ad54a | ||
|
|
692244d213 | ||
|
|
d98a305985 | ||
|
|
670325e519 | ||
|
|
54237af7ad | ||
|
|
b6ceccca26 | ||
|
|
36a45068ab | ||
|
|
055af368cd | ||
|
|
eb9e3e69a9 | ||
|
|
b7cf0efb9d | ||
|
|
125c99bb46 | ||
|
|
3e537bd582 | ||
|
|
4e6fc7aeee | ||
|
|
9a232ad444 | ||
|
|
3da0eca9b4 | ||
|
|
7d1213451c | ||
|
|
7c9293f4ed | ||
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 | ||
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 | ||
|
|
f0055483a3 | ||
|
|
6d014c1d0f | ||
|
|
1001acfb44 | ||
|
|
6ad275b505 | ||
|
|
b600cc986c | ||
|
|
9bdab1c48c | ||
|
|
6520c2fc4b |
+5
-1
@@ -64,7 +64,11 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients.
|
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
||||||
|
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
||||||
|
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
||||||
|
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
||||||
|
accept only paths inside `brain/raw/`.
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -7,6 +7,29 @@
|
|||||||
"supervisor": {
|
"supervisor": {
|
||||||
"url": "http://localhost:3200/mcp",
|
"url": "http://localhost:3200/mcp",
|
||||||
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||||
|
},
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-26
@@ -53,14 +53,51 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
|
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||||
|
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||||
|
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||||
|
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||||
|
PR would buy a review of a state that was never deployed.
|
||||||
|
|
||||||
|
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||||
|
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||||
|
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||||
|
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||||
|
green" is not the same claim.
|
||||||
|
|
||||||
|
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||||
|
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||||
|
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||||
|
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||||
|
|
||||||
|
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||||
|
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||||
|
lane silently does not apply.
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -78,6 +115,63 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||||
|
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||||
|
landed, the state changed, you verified it live — close/merge/check it off
|
||||||
|
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||||
|
on me to confirm something that already happened; that's bookkeeping, not
|
||||||
|
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||||
|
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||||
|
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||||
|
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||||
|
destructive-git already gate. Before claiming something is closable,
|
||||||
|
verify it live (re-check the issue, re-run the command) rather than
|
||||||
|
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||||
|
|
||||||
|
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||||
|
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||||
|
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||||
|
passing suite only asserts what its author already thought of, and for a
|
||||||
|
control the author is precisely the person whose imagination just failed.
|
||||||
|
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||||
|
execution, and **CI was green on every one of them** while `git rm
|
||||||
|
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||||
|
route this work through a review with authority to block, instructed to
|
||||||
|
verify by execution rather than by reading the implementation. Expect to be
|
||||||
|
blocked more than once; that is the control working, not the reviewer being
|
||||||
|
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||||
|
practices live in the `pr-gatekeeper` agent.)
|
||||||
|
|
||||||
|
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||||
|
after the mechanism it observes, not the goal it serves.
|
||||||
|
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||||
|
support. This is not pedantry about naming — when name and assertion
|
||||||
|
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||||
|
which is what lets the defect survive review. Found three times in one PR,
|
||||||
|
twice in tests written to fix the previous instance of it; each passed while
|
||||||
|
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||||
|
you do not yet know what the test observes.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -88,7 +182,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -97,7 +191,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -135,10 +234,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -205,10 +304,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||||
|
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||||
|
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||||
|
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||||
|
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||||
|
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||||
|
were not.)
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -234,10 +339,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -249,26 +355,65 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||||
unconditionally on every host, every harness.
|
host and every harness.
|
||||||
|
|
||||||
|
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||||
|
must state its reason.** Both halves matter: the project file wins because it knows
|
||||||
|
something this file cannot (infra's Flux constraint is real and not visible from
|
||||||
|
here), and it must say why because a bare override is indistinguishable from drift.
|
||||||
|
|
||||||
|
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||||
|
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||||
|
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||||
|
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||||
|
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||||
|
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
@@ -348,7 +493,11 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients.
|
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
||||||
|
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
||||||
|
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
||||||
|
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
||||||
|
accept only paths inside `brain/raw/`.
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Presence of this file marks this repo as opt-in for headless dispatch.
|
||||||
|
# See dispatch#3.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# Supervisor MCP server
|
# Supervisor MCP server
|
||||||
SUPERVISOR_PORT=3200
|
SUPERVISOR_PORT=3200
|
||||||
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
||||||
SUPERVISOR_MODELS_FILE=./config/models.yaml
|
|
||||||
|
|
||||||
# LiteLLM gateway (iguana)
|
# LiteLLM gateway (iguana)
|
||||||
LITELLM_BASE_URL=http://iguana:4000
|
LITELLM_BASE_URL=http://iguana:4000
|
||||||
|
|||||||
+36
-8
@@ -45,12 +45,32 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
# infra#311: github.sha is never empty in practice, but the manifest
|
||||||
|
# patch below is the same shape that took Flux down estate-wide on
|
||||||
|
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
||||||
|
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
||||||
|
# assert costs nothing and removes the class.
|
||||||
|
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
# In-cluster DNS to gitea's SSH NodePort service, not 127.0.0.1:30022
|
||||||
|
# (that only worked when act_runner ran on koala's bare host network;
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# from inside the containerized runner's own pod netns, loopback
|
||||||
|
# never reaches the host — "Connection refused", found 2026-07-27).
|
||||||
|
#
|
||||||
|
# Pass as -o overrides on the ssh invocation itself, NOT appended to
|
||||||
|
# ~/.ssh/config: $HOME (/data) is a PVC that persists across job
|
||||||
|
# runs on this runner (same "workspace not ephemeral" class as
|
||||||
|
# brain: act-runner-host-executor-tmp-persists), so an appended
|
||||||
|
# line here would pile up duplicate `Host git.d-ma.be` blocks
|
||||||
|
# across every run — ssh_config is first-match-wins, so a stale
|
||||||
|
# entry from an earlier failed run would silently shadow this
|
||||||
|
# fix forever (exactly what happened once already: this fix's
|
||||||
|
# own first attempt got appended AFTER an already-stale entry
|
||||||
|
# and lost). CLI -o options always win regardless of file state,
|
||||||
|
# so this step is safe to re-run any number of times.
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes -o HostName=gitea-ssh-nodeport.gitea.svc.cluster.local -o Port=22 -o StrictHostKeyChecking=no" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
cd /tmp/infra-update
|
cd /tmp/infra-update
|
||||||
@@ -61,11 +81,19 @@ jobs:
|
|||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# finds the bump already applied failed the whole job and rang the
|
||||||
git push
|
# CI-red alarm for a deploy that was fine (hyperguild#118). Two
|
||||||
|
# sources of a no-op run: a manual re-run, and the tag-push CI run
|
||||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
# that used to shadow the branch-push one for the same sha.
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Infra already at ${IMAGE_TAG} — nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git push
|
||||||
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ name: CI
|
|||||||
|
|
||||||
"on":
|
"on":
|
||||||
push:
|
push:
|
||||||
|
# Branch only. A `v*` tag here points at a commit main already tested, so
|
||||||
|
# the tag push re-ran the whole gate and — because cd.yml keys off "CI
|
||||||
|
# succeeded" — triggered a SECOND full image build and deploy for the same
|
||||||
|
# sha. That second deploy then died on "nothing to commit" (hyperguild#118).
|
||||||
|
# Nothing consumes a tag-triggered run: there is no release workflow, and
|
||||||
|
# cd.yml reads github.sha, not the tag.
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit secret scan for the hyperguild repo.
|
||||||
|
#
|
||||||
|
# task check -> security:gitleaks enforces this in CI, but CI is after the
|
||||||
|
# push. This catches a staged secret before it ever leaves the machine --
|
||||||
|
# the infra#39 rotation pass found exactly this class of leak (a printed
|
||||||
|
# credential, committed, archived into git) and there was no local gate to
|
||||||
|
# have caught it. Mirrors infra's own .githooks/pre-commit (infra#326).
|
||||||
|
#
|
||||||
|
# Enable once per clone: git config core.hooksPath .githooks
|
||||||
|
# Bypass if absolutely needed: git commit --no-verify
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITLEAKS="$(command -v gitleaks || true)"
|
||||||
|
if [[ -z "$GITLEAKS" && -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ]]; then
|
||||||
|
GITLEAKS="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$GITLEAKS" ]]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping local secret scan (CI still enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Scanning staged changes for secrets..."
|
||||||
|
if ! "$GITLEAKS" protect --staged --redact --config .gitleaks.toml >/dev/null 2>&1; then
|
||||||
|
echo " ✗ gitleaks found a secret in the staged changes."
|
||||||
|
echo " Review with: gitleaks protect --staged --redact --config .gitleaks.toml -v"
|
||||||
|
echo " If it is a false positive, allowlist it in .gitleaks.toml or add a"
|
||||||
|
echo " '# gitleaks:allow' comment. Do NOT --no-verify a real secret."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ no staged secrets"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# gitleaks config for the hyperguild repo (infra#39 — leak prevention pass,
|
||||||
|
# Phase 3 checklist item: "gitleaks pre-commit hook in infra AND hyperguild").
|
||||||
|
#
|
||||||
|
# Ported from mathias/infra's .gitleaks.toml (2026-08-04) — same homelab
|
||||||
|
# token-shape rules, minus the SOPS/searxng allowlists infra needed (this
|
||||||
|
# repo doesn't use SOPS).
|
||||||
|
|
||||||
|
title = "hyperguild gitleaks config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# --- Homelab-specific rules -------------------------------------------------
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-static-bearer"
|
||||||
|
description = "Homelab MCP/LLM static bearer or API key assigned a long literal value"
|
||||||
|
regex = '''(?i)\b(DMABE_[A-Z0-9_]+|[A-Z0-9_]*MCP_TOKEN|ROUTING_MCP_TOKEN|INFRA_MCP_TOKEN|BRAIN_MCP_TOKEN|GITEA_MCP_TOKEN|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|DMABE_LLMAPI_KEY|BRAIN_PG_DSN)\s*[:=]\s*['"]?([A-Za-z0-9/_+.\-]{16,})['"]?'''
|
||||||
|
keywords = ["dmabe_", "mcp_token", "litellm_master_key", "litellm_salt_key", "llmapi_key", "brain_pg_dsn"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''os\.environ''',
|
||||||
|
'''valueFrom''',
|
||||||
|
'''secretKeyRef''',
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-authorization-bearer"
|
||||||
|
description = "Hardcoded Authorization: Bearer header"
|
||||||
|
regex = '''(?i)authorization['"]?\s*[:=]\s*['"]?bearer\s+([A-Za-z0-9/_+.\-=]{16,})'''
|
||||||
|
keywords = ["authorization", "bearer"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''os\.environ''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Global allowlist: claudewatcher's own scrubber test fixtures ------------
|
||||||
|
# ingestion/internal/claudewatcher/{scrubber,watcher}_test.go deliberately
|
||||||
|
# contain fake secret-shaped literals to test that the scrubber detects and
|
||||||
|
# redacts them. Verified 2026-08-04: all 9 findings here are test fixtures
|
||||||
|
# (github-pat, jwt, generic-api-key, homelab-authorization-bearer rules) plus
|
||||||
|
# 1 doc finding that was gitleaks matching the literal placeholder word
|
||||||
|
# "REDACTED" in a plan doc — not a real secret in either case.
|
||||||
|
[[allowlists]]
|
||||||
|
description = "claudewatcher scrubber test fixtures — deliberately fake secrets"
|
||||||
|
paths = [
|
||||||
|
'''ingestion/internal/claudewatcher/scrubber_test\.go$''',
|
||||||
|
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
||||||
|
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
||||||
|
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
||||||
|
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
||||||
|
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
||||||
|
# an `Authorization: token ...` header. Confirmed dead by side-effect — GET
|
||||||
|
# /api/v1/user with it returns 401 — and redacted in that file, so there is now
|
||||||
|
# nothing to allowlist.
|
||||||
|
#
|
||||||
|
# The `paths` entry also made the block far broader than its description: gitleaks
|
||||||
|
# ORs global allowlist clauses and ignores `condition = "AND"`, so the whole file
|
||||||
|
# was immune, not just lines matching /REDACTED/. A live token added to that doc
|
||||||
|
# would have been suppressed on the strength of a note about a different value.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Historical gitleaks findings, dispositioned (infra#326 / infra#317).
|
||||||
|
#
|
||||||
|
# WHY THIS FILE AND NOT .gitleaks.toml:
|
||||||
|
# This is a finding in git HISTORY, surfaced by the full-history audit
|
||||||
|
# (`task security:gitleaks:history`). The working-tree scan that gates commits
|
||||||
|
# (`task security:gitleaks`, the pre-commit hook, CI) is clean. Suppressing by
|
||||||
|
# PATH in .gitleaks.toml would also blind the working-tree scan for that file
|
||||||
|
# forever; a fingerprint pins one finding in one commit, so a NEW secret in the
|
||||||
|
# same file still fires.
|
||||||
|
#
|
||||||
|
# History is not rewritten here, so the remediation for a real leak is
|
||||||
|
# ROTATION -- making the committed value worthless -- never scrubbing.
|
||||||
|
#
|
||||||
|
# Dispositioned 2026-08-29. Re-check with: task security:gitleaks:history
|
||||||
|
|
||||||
|
# --- Real credential, ALREADY DEAD -------------------------------------------
|
||||||
|
# A Gitea PAT pasted into a CD-watching command in a planning doc
|
||||||
|
# (`Authorization: token <40-hex>`, last_eight 3105a670).
|
||||||
|
#
|
||||||
|
# Verified dead 2026-08-29: authenticating against
|
||||||
|
# GET https://git.d-ma.be/api/v1/user with this exact value returns 401, so the
|
||||||
|
# committed token no longer grants anything. It was almost certainly invalidated
|
||||||
|
# by the early-August rotation of the `mathias` PATs (the same rotation whose
|
||||||
|
# fan-out caused infra#260, where CI copies of the old value kept failing).
|
||||||
|
#
|
||||||
|
# No rotation action outstanding. Kept as a fingerprint rather than removed from
|
||||||
|
# history because history cannot be rewritten here.
|
||||||
|
#
|
||||||
|
# The generalisable lesson is recorded separately: a credential printed into a
|
||||||
|
# doc/transcript is permanent, so the only remedy is rotating it -- which in
|
||||||
|
# this case had already happened for unrelated reasons, by luck rather than by
|
||||||
|
# the leak being noticed.
|
||||||
|
c9310b1079478bf4430b0a2dfb25445d7ccc9b59:docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md:curl-auth-header:1053
|
||||||
@@ -48,14 +48,51 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
|
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||||
|
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||||
|
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||||
|
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||||
|
PR would buy a review of a state that was never deployed.
|
||||||
|
|
||||||
|
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||||
|
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||||
|
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||||
|
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||||
|
green" is not the same claim.
|
||||||
|
|
||||||
|
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||||
|
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||||
|
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||||
|
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||||
|
|
||||||
|
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||||
|
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||||
|
lane silently does not apply.
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -73,6 +110,63 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||||
|
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||||
|
landed, the state changed, you verified it live — close/merge/check it off
|
||||||
|
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||||
|
on me to confirm something that already happened; that's bookkeeping, not
|
||||||
|
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||||
|
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||||
|
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||||
|
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||||
|
destructive-git already gate. Before claiming something is closable,
|
||||||
|
verify it live (re-check the issue, re-run the command) rather than
|
||||||
|
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||||
|
|
||||||
|
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||||
|
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||||
|
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||||
|
passing suite only asserts what its author already thought of, and for a
|
||||||
|
control the author is precisely the person whose imagination just failed.
|
||||||
|
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||||
|
execution, and **CI was green on every one of them** while `git rm
|
||||||
|
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||||
|
route this work through a review with authority to block, instructed to
|
||||||
|
verify by execution rather than by reading the implementation. Expect to be
|
||||||
|
blocked more than once; that is the control working, not the reviewer being
|
||||||
|
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||||
|
practices live in the `pr-gatekeeper` agent.)
|
||||||
|
|
||||||
|
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||||
|
after the mechanism it observes, not the goal it serves.
|
||||||
|
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||||
|
support. This is not pedantry about naming — when name and assertion
|
||||||
|
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||||
|
which is what lets the defect survive review. Found three times in one PR,
|
||||||
|
twice in tests written to fix the previous instance of it; each passed while
|
||||||
|
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||||
|
you do not yet know what the test observes.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -83,7 +177,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -92,7 +186,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -130,10 +229,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -200,10 +299,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||||
|
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||||
|
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||||
|
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||||
|
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||||
|
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||||
|
were not.)
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -229,10 +334,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -244,26 +350,65 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||||
unconditionally on every host, every harness.
|
host and every harness.
|
||||||
|
|
||||||
|
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||||
|
must state its reason.** Both halves matter: the project file wins because it knows
|
||||||
|
something this file cannot (infra's Flux constraint is real and not visible from
|
||||||
|
here), and it must say why because a bare override is indistinguishable from drift.
|
||||||
|
|
||||||
|
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||||
|
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||||
|
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||||
|
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||||
|
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||||
|
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
@@ -343,7 +488,11 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients.
|
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
||||||
|
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
||||||
|
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
||||||
|
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
||||||
|
accept only paths inside `brain/raw/`.
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients.
|
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
||||||
|
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
||||||
|
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
||||||
|
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
||||||
|
accept only paths inside `brain/raw/`.
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
|||||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
into a searchable brain.
|
into a searchable brain.
|
||||||
|
|
||||||
|
**This repo is also where `brain-mcp` lives** — the MCP server at
|
||||||
|
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
|
||||||
|
and `capture` are served from. It is not a separate deployable; see
|
||||||
|
[Where brain-mcp lives](#where-brain-mcp-lives).
|
||||||
|
|
||||||
## Hypothesis
|
## Hypothesis
|
||||||
|
|
||||||
> We believe routing skill tasks through local models, backed by brain context,
|
> We believe routing skill tasks through local models, backed by brain context,
|
||||||
@@ -20,7 +25,7 @@ usage, the fast-model routing path should be reconsidered.
|
|||||||
|
|
||||||
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||||
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||||
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||||
orchestration model. Skills (mathias/skills) are shared between both.
|
orchestration model. Skills (mathias/skills) are shared between both.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -44,6 +49,91 @@ brain/
|
|||||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Where brain-mcp lives
|
||||||
|
|
||||||
|
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
|
||||||
|
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
|
||||||
|
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
|
||||||
|
endpoint, the REST API, and the claudewatcher together.
|
||||||
|
|
||||||
|
| You are looking for | It is here |
|
||||||
|
|---|---|
|
||||||
|
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
|
||||||
|
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
|
||||||
|
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
|
||||||
|
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
|
||||||
|
| Deployment manifests | `config/supervisor` |
|
||||||
|
|
||||||
|
### Why it is not its own repo
|
||||||
|
|
||||||
|
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
|
||||||
|
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
|
||||||
|
has exactly one importer — it is the MCP facade over the service's own
|
||||||
|
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
|
||||||
|
packages as a shared library, or maintaining a shim. The discoverability problem
|
||||||
|
that prompted the question is answered by naming it here instead.
|
||||||
|
|
||||||
|
Full reasoning, including the reversal trigger: brain
|
||||||
|
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||||
|
|
||||||
|
## Ingest revises, it does not overwrite
|
||||||
|
|
||||||
|
Re-ingesting a source **merges into the page already on disk** rather than
|
||||||
|
replacing it. Previously the write was unconditional, so a second ingest of the
|
||||||
|
same source discarded everything accumulated since — including hand-written
|
||||||
|
sections.
|
||||||
|
|
||||||
|
The merge is deliberately asymmetric in the existing page's favour:
|
||||||
|
|
||||||
|
| On the page | In the fresh extraction | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| a section | the same section | **the on-disk version wins** |
|
||||||
|
| — | a new section | added |
|
||||||
|
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
|
||||||
|
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
|
||||||
|
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
|
||||||
|
|
||||||
|
So an extraction can add to a note but never silently rewrite what a human put
|
||||||
|
there, which is what makes it safe to run on every ingest rather than behind a
|
||||||
|
review gate.
|
||||||
|
|
||||||
|
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||||
|
does not commit noise that hides real edits.
|
||||||
|
|
||||||
|
Two fields are **generator-owned** and do get refreshed on a revise:
|
||||||
|
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
||||||
|
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
||||||
|
|
||||||
|
### Disagreement is reported, not resolved
|
||||||
|
|
||||||
|
When the fresh extraction produces a *different version* of a section that
|
||||||
|
already exists, the on-disk version still wins — but the run now says so:
|
||||||
|
|
||||||
|
```
|
||||||
|
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
|
||||||
|
```
|
||||||
|
|
||||||
|
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
|
||||||
|
source is unchanged" and "the source changed its mind and we ignored it" look
|
||||||
|
identical from the outside.
|
||||||
|
|
||||||
|
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
|
||||||
|
deterministic, no model on the write path. Deciding whether a disagreement is a
|
||||||
|
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
|
||||||
|
approval-gated by design. Detection here, adjudication there.
|
||||||
|
|
||||||
|
## Re-ingesting an unchanged source is skipped
|
||||||
|
|
||||||
|
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||||
|
excluded — on the source page. A later ingest of the same source compares it
|
||||||
|
first and skips extraction entirely when it matches, reporting the skip rather
|
||||||
|
than doing it silently.
|
||||||
|
|
||||||
|
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
|
||||||
|
the source, and treating that as drift would re-extract the corpus every time a
|
||||||
|
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
|
||||||
|
costs one extraction, a wrong skip loses an update.
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
@@ -58,7 +148,7 @@ brain/
|
|||||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||||
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
|
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
|
||||||
|
|
||||||
## Connect a project
|
## Connect a project
|
||||||
|
|
||||||
@@ -147,7 +237,7 @@ The routing pod selects models per skill call based on historical pass rate:
|
|||||||
|
|
||||||
## Open issues
|
## Open issues
|
||||||
|
|
||||||
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||||
|
|
||||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||||
- **#24** — reduce context burn from skill listing
|
- **#24** — reduce context burn from skill listing
|
||||||
|
|||||||
+74
-36
@@ -21,7 +21,7 @@ tasks:
|
|||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
start:
|
start:
|
||||||
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
||||||
cmds:
|
cmds:
|
||||||
- goreman start
|
- goreman start
|
||||||
|
|
||||||
@@ -32,11 +32,6 @@ tasks:
|
|||||||
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||||
- echo "hyperguild stopped"
|
- echo "hyperguild stopped"
|
||||||
|
|
||||||
supervisor:dev:
|
|
||||||
desc: Run supervisor MCP server (development)
|
|
||||||
cmds:
|
|
||||||
- go run ./cmd/supervisor
|
|
||||||
|
|
||||||
hyperguild:dev:
|
hyperguild:dev:
|
||||||
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||||
cmds:
|
cmds:
|
||||||
@@ -67,14 +62,9 @@ tasks:
|
|||||||
build:
|
build:
|
||||||
desc: Build all binaries
|
desc: Build all binaries
|
||||||
cmds:
|
cmds:
|
||||||
- task: supervisor:build
|
- task: hyperguild:build
|
||||||
- task: ingestion:build
|
- task: ingestion:build
|
||||||
|
|
||||||
supervisor:build:
|
|
||||||
desc: Build supervisor binary
|
|
||||||
cmds:
|
|
||||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
|
||||||
|
|
||||||
ingestion:build:
|
ingestion:build:
|
||||||
desc: Build ingestion server binary
|
desc: Build ingestion server binary
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
@@ -84,29 +74,90 @@ tasks:
|
|||||||
# ── Quality ────────────────────────────────────────────────────────────────
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
- task: context:sync
|
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||||
|
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||||
|
# the tree for the canonical .context/AGENT.md, which lives in local-dev
|
||||||
|
# and does not exist on a CI runner. Without this guard, the script
|
||||||
|
# silently falls back to "project context only" and regenerates AGENTS.md
|
||||||
|
# and .context/system-prompt.txt shorter than their committed, correct
|
||||||
|
# versions -- so the drift assertion fails every CI run for a purely
|
||||||
|
# environmental reason, not real drift. Verified, not assumed: this is
|
||||||
|
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
|
||||||
|
# 82-line regeneration on the runner).
|
||||||
|
#
|
||||||
|
# The cost is real and worth stating: drift is therefore only ever caught
|
||||||
|
# on a developer machine, or by infra's estate-wide context-sync-drift
|
||||||
|
# CronJob (ADR-0019), which does have canonical access and is the actual
|
||||||
|
# enforcement mechanism now.
|
||||||
- cmd: |
|
- cmd: |
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
if [ -n "${CI:-}" ]; then
|
||||||
if [ -n "$drift" ]; then
|
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
else
|
||||||
echo "$drift" >&2
|
bash scripts/context-sync.sh
|
||||||
echo "" >&2
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
if [ -n "$drift" ]; then
|
||||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
exit 1
|
echo "$drift" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
||||||
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
fi
|
fi
|
||||||
echo "✓ context: canonical and adapters are in sync"
|
|
||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
# `build` is in the gate deliberately (hyperguild#120). vet and test walk
|
||||||
|
# ./... , which enumerates the packages that EXIST — so a build target
|
||||||
|
# naming a DELETED package is invisible to both. `task build` pointed at
|
||||||
|
# ./cmd/supervisor for months after that directory was removed, and CI
|
||||||
|
# stayed green the whole time because nothing in check ever built.
|
||||||
|
- task: build
|
||||||
|
- task: security:gitleaks
|
||||||
|
|
||||||
|
# ── Security ─────────────────────────────────────────────
|
||||||
|
security:gitleaks:
|
||||||
|
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
|
||||||
|
|
||||||
|
security:gitleaks:history:
|
||||||
|
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
|
||||||
|
echo "until the infra#39 rotation pass completes — triage against the rotation list."
|
||||||
|
"$GL" detect --redact --config .gitleaks.toml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
- golangci-lint run ./...
|
||||||
- cd ingestion && golangci-lint run ./...
|
- cd ingestion && golangci-lint run ./...
|
||||||
|
|
||||||
|
lint:frontmatter:
|
||||||
|
desc: Report brain notes whose YAML frontmatter does not parse (BRAIN=path, FIX=1 repairs in place)
|
||||||
|
cmds:
|
||||||
|
- cd ingestion && go run ./cmd/frontmatterlint {{if .FIX}}-fix{{end}} {{.BRAIN | default "../../brain"}}
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cmds:
|
cmds:
|
||||||
- go test -race -count=1 ./...
|
- go test -race -count=1 ./...
|
||||||
@@ -118,19 +169,6 @@ tasks:
|
|||||||
- govulncheck ./... || true
|
- govulncheck ./... || true
|
||||||
- cd ingestion && go vet ./...
|
- cd ingestion && go vet ./...
|
||||||
|
|
||||||
supervisor:test:smoke:
|
|
||||||
desc: Smoke test supervisor via MCP (requires start running)
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
|
||||||
|
|
||||||
smoke:routing:
|
|
||||||
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
|
||||||
cmds:
|
|
||||||
- bash scripts/smoke-routing.sh
|
|
||||||
|
|
||||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
@@ -55,18 +55,21 @@ Flags:
|
|||||||
- `--limit N` — max results (default 5)
|
- `--limit N` — max results (default 5)
|
||||||
- `--json` — emit the raw response envelope
|
- `--json` — emit the raw response envelope
|
||||||
|
|
||||||
### `hyperguild brain write <type> <slug>`
|
### `hyperguild brain write <wing> <hall> <slug>`
|
||||||
|
|
||||||
Reads markdown from stdin, writes a knowledge entry.
|
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
|
||||||
|
with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy
|
||||||
|
`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are
|
||||||
|
required).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
$ cat <<EOF | hyperguild brain write homelab facts example-lesson
|
||||||
# Example lesson
|
# Example lesson
|
||||||
|
|
||||||
## Lesson
|
## Lesson
|
||||||
...
|
...
|
||||||
EOF
|
EOF
|
||||||
knowledge/example-lesson.md
|
wiki/homelab/facts/example-lesson.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hyperguild brain pass-rate <skill>`
|
### `hyperguild brain pass-rate <skill>`
|
||||||
|
|||||||
@@ -60,13 +60,14 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("parse flags: %w", err)
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() < 2 {
|
if fs.NArg() < 3 {
|
||||||
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
|
||||||
}
|
}
|
||||||
kind := fs.Arg(0)
|
wing := fs.Arg(0)
|
||||||
slug := fs.Arg(1)
|
hall := fs.Arg(1)
|
||||||
|
slug := fs.Arg(2)
|
||||||
|
|
||||||
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
|
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "knowledge", "test-slug"},
|
[]string{"write", "homelab", "facts", "test-slug"},
|
||||||
strings.NewReader("# Test\n\nSome body content.\n"),
|
strings.NewReader("# Test\n\nSome body content.\n"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "type and slug required")
|
assert.Contains(t, err.Error(), "wing, hall and slug required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunBrainWrite_BackendError(t *testing.T) {
|
func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||||
@@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "knowledge", "bad slug"},
|
[]string{"write", "homelab", "facts", "bad slug"},
|
||||||
strings.NewReader("body"),
|
strings.NewReader("body"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
|
|||||||
t.Setenv("BRAIN_URL", srv.URL)
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-10
@@ -20,6 +20,7 @@ const defaultBrainURL = "http://koala:30330"
|
|||||||
// shell-friendly client; MCP framing is unnecessary.
|
// shell-friendly client; MCP framing is unnecessary.
|
||||||
type brainClient struct {
|
type brainClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ func newBrainClient() *brainClient {
|
|||||||
}
|
}
|
||||||
return &brainClient{
|
return &brainClient{
|
||||||
baseURL: u,
|
baseURL: u,
|
||||||
|
token: os.Getenv("BRAIN_MCP_TOKEN"),
|
||||||
http: &http.Client{Timeout: 5 * time.Second},
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,8 +64,7 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.do(req)
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain POST /query: %w", err)
|
return nil, fmt.Errorf("brain POST /query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,16 +85,17 @@ type WriteResult struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
|
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
|
||||||
body, err := io.ReadAll(content)
|
body, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read content: %w", err)
|
return nil, fmt.Errorf("read content: %w", err)
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(struct {
|
payload, err := json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Wing string `json:"wing"`
|
||||||
Slug string `json:"slug"`
|
Hall string `json:"hall"`
|
||||||
Content string `json:"content"`
|
Filename string `json:"filename"`
|
||||||
}{Type: kind, Slug: slug, Content: string(body)})
|
Content string `json:"content"`
|
||||||
|
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
@@ -104,8 +106,7 @@ func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.R
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.do(req)
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain POST /write: %w", err)
|
return nil, fmt.Errorf("brain POST /write: %w", err)
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
|
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -157,3 +158,13 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
|||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// do sends every brain request, adding the bearer token when one is
|
||||||
|
// configured. All methods go through here so none can forget it (PassRate
|
||||||
|
// did, PR #152 review).
|
||||||
|
func (c *brainClient) do(req *http.Request) (*http.Response, error) {
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI
|
||||||
|
// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call.
|
||||||
|
func TestBrainClientSendsBrainMCPTokenOnQueryWriteAndPassRate(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = append(got, r.URL.Path+" "+r.Header.Get("Authorization"))
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/write":
|
||||||
|
_, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`))
|
||||||
|
return
|
||||||
|
case "/pass-rate":
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token")
|
||||||
|
|
||||||
|
c := newBrainClient()
|
||||||
|
_, err := c.Query(context.Background(), "x", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// PR #152 review: PassRate sent no token and would 401 in production.
|
||||||
|
_, _ = c.PassRate(context.Background(), "tdd", "7d")
|
||||||
|
assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token",
|
||||||
|
"/pass-rate Bearer cli-test-token"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) {
|
||||||
|
var auth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth = r.Header.Get("Authorization")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
t.Setenv("BRAIN_MCP_TOKEN", "")
|
||||||
|
_, err := newBrainClient().Query(context.Background(), "x", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, auth)
|
||||||
|
}
|
||||||
@@ -65,23 +65,25 @@ func TestBrainClient_Write_Success(t *testing.T) {
|
|||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var got struct {
|
var got struct {
|
||||||
Type string `json:"type"`
|
Wing string `json:"wing"`
|
||||||
Slug string `json:"slug"`
|
Hall string `json:"hall"`
|
||||||
Content string `json:"content"`
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(body, &got))
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
assert.Equal(t, "knowledge", got.Type)
|
assert.Equal(t, "homelab", got.Wing)
|
||||||
assert.Equal(t, "find-h", got.Slug)
|
assert.Equal(t, "facts", got.Hall)
|
||||||
|
assert.Equal(t, "find-h", got.Filename)
|
||||||
assert.Equal(t, "# body\n", got.Content)
|
assert.Equal(t, "# body\n", got.Content)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
|
_, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainClient_PassRate_Success(t *testing.T) {
|
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAnthropicProbe = "https://api.anthropic.com"
|
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Model selection — first entry per skill is used.
|
|
||||||
# Override per-call by passing model in the MCP tool args.
|
|
||||||
# Model names come from LiteLLM /v1/models (host/name format).
|
|
||||||
|
|
||||||
default_chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
|
|
||||||
skills:
|
|
||||||
tdd:
|
|
||||||
chain:
|
|
||||||
- koala/qwen3-coder-30b
|
|
||||||
review:
|
|
||||||
chain:
|
|
||||||
- iguana/devstral
|
|
||||||
debug:
|
|
||||||
chain:
|
|
||||||
- iguana/deepseek-r1-14b
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
- koala/phi4-14b
|
|
||||||
retrospective:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
trainer:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
@@ -24,7 +24,7 @@ Only a `tools/call` to the routing pod records a pass/fail.
|
|||||||
|---------|-----|------|
|
|---------|-----|------|
|
||||||
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||||
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||||
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | Bearer `BRAIN_MCP_TOKEN` (since hyperguild#149) |
|
||||||
|
|
||||||
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||||
plus `session_log`, `retrospective`, `trainer`.
|
plus `session_log`, `retrospective`, `trainer`.
|
||||||
|
|||||||
@@ -1051,7 +1051,7 @@ git push
|
|||||||
```bash
|
```bash
|
||||||
# Poll until complete (replace RUN_ID with the new run):
|
# Poll until complete (replace RUN_ID with the new run):
|
||||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||||
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
-H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
|
||||||
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Three needs converge on a single small Go binary:
|
|||||||
|
|
||||||
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
||||||
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
||||||
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
|
- [ ] `hyperguild brain write <wing> <hall> <slug>` reads markdown content from stdin, posts to `/write` with wing/hall/filename, and creates `brain/wiki/<wing>/<hall>/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry. (Updated post-ADR-0012/#79: the legacy `<type> <slug>` shape and the `brain/knowledge/` fallback were removed; wing and hall are now required.)
|
||||||
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
||||||
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
||||||
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
||||||
@@ -50,7 +50,7 @@ Three needs converge on a single small Go binary:
|
|||||||
|
|
||||||
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
||||||
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
||||||
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
- `brain write <wing> <hall> <slug>` reads stdin, posts `POST /write` with JSON body `{wing, hall, filename, content}`, prints the resulting path on success. (Updated post-ADR-0012/#79: wing and hall are required, replacing the old `<type> <slug>` shape and the `brain/knowledge/` legacy fallback.)
|
||||||
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
||||||
|
|
||||||
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/mathiasbq/supervisor
|
module git.d-ma.be/mathias/hyperguild
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Command frontmatterlint reports markdown notes whose YAML frontmatter does
|
||||||
|
// not parse, and optionally repairs the ones that only need quoting.
|
||||||
|
//
|
||||||
|
// go run ./cmd/frontmatterlint ../../brain # report
|
||||||
|
// go run ./cmd/frontmatterlint -fix ../../brain # repair in place
|
||||||
|
//
|
||||||
|
// Exit codes: 0 clean (or everything repaired), 1 findings remain, 2 the scan
|
||||||
|
// itself failed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fix := flag.Bool("fix", false, "repair files that quoting can rescue, in place")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := frontmatter.Scan(root, *fix)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "frontmatterlint:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved int
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Repaired {
|
||||||
|
fmt.Printf("repaired %s\n", f.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unresolved++
|
||||||
|
fmt.Printf("BROKEN %s: %v\n", f.Path, f.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unresolved > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d file(s) with unparseable frontmatter", unresolved)
|
||||||
|
if !*fix {
|
||||||
|
fmt.Fprint(os.Stderr, " — re-run with -fix")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ frontmatter: %d file(s) repaired, none broken\n", len(findings))
|
||||||
|
}
|
||||||
+147
-46
@@ -32,6 +32,8 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||||
@@ -143,6 +145,48 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
|
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||||
|
// alertingWarnings names every alert that will not fire under the current
|
||||||
|
// configuration (#107).
|
||||||
|
//
|
||||||
|
// buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and every
|
||||||
|
// consumer skips a nil notifier silently — right for an unconfigured homelab,
|
||||||
|
// wrong for an alert whose entire value is that someone finds out. The cost of
|
||||||
|
// the silence is that "configured and working" and "never configured" are
|
||||||
|
// byte-identical in behaviour: no error, no warning, only a message that never
|
||||||
|
// arrives, which nobody notices because nobody is waiting for an alert they
|
||||||
|
// have never seen. Two alerts were dark in production for exactly that reason
|
||||||
|
// (#100), found by firing a real event rather than by reading anything.
|
||||||
|
//
|
||||||
|
// WARN rather than fatal, deliberately. The brain sibling rule
|
||||||
|
// (`empty-credential-must-refuse-to-start-not-switch-modes`) says an empty
|
||||||
|
// credential must be a startup error, never a silent mode switch — but that is
|
||||||
|
// about a control being removed. Here observability is degraded, not a gate
|
||||||
|
// bypassed, and refusing to boot would break the unconfigured-homelab case the
|
||||||
|
// nil notifier exists to support. What the rule does demand, and what this
|
||||||
|
// provides, is that the mode switch stops being silent.
|
||||||
|
//
|
||||||
|
// Pure and env-injected so the message is testable without a running server:
|
||||||
|
// the reason this gap survived is that nothing about it was observable.
|
||||||
|
func alertingWarnings(getenv func(string) string) []string {
|
||||||
|
var out []string
|
||||||
|
if getenv("BRAIN_NTFY_URL") == "" {
|
||||||
|
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
|
||||||
|
"brain_edit_root governance rewrites, capture audit degradation and the "+
|
||||||
|
"brain symlink tripwire (hyperguild#157) will not notify")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNotifier() audit.Notifier {
|
||||||
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||||
// set it builds the classification-aware DegradingSink (loki central +
|
// set it builds the classification-aware DegradingSink (loki central +
|
||||||
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||||
@@ -160,12 +204,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
|
|||||||
logger.Error("capture audit buffer init", "err", err)
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
notifier := buildNotifier()
|
||||||
// so DegradingSink/Reconcile skip it cleanly.
|
|
||||||
var notifier audit.Notifier
|
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
|
||||||
notifier = n
|
|
||||||
}
|
|
||||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
@@ -197,7 +236,15 @@ func systemHostname() string {
|
|||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
for _, w := range alertingWarnings(os.Getenv) {
|
||||||
|
logger.Warn(w)
|
||||||
|
}
|
||||||
|
|
||||||
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
||||||
|
if err := startupSymlinkCheck(brainDir); err != nil {
|
||||||
|
logger.Error("brain symlink tripwire", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
port := envOr("INGEST_PORT", "3300")
|
port := envOr("INGEST_PORT", "3300")
|
||||||
|
|
||||||
llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1")
|
llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1")
|
||||||
@@ -230,6 +277,11 @@ func main() {
|
|||||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||||
}
|
}
|
||||||
|
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
||||||
|
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
||||||
|
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
||||||
|
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
||||||
|
}
|
||||||
answerComplete = router.Complete
|
answerComplete = router.Complete
|
||||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||||
}
|
}
|
||||||
@@ -314,6 +366,38 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
// hyperguild#157: report a symlink that appears in the brain while running.
|
||||||
|
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
|
||||||
|
|
||||||
|
// Built before the watcher, the graph backfill and session ingestion, and
|
||||||
|
// retried rather than fatal (#83). Two separate reasons:
|
||||||
|
//
|
||||||
|
// - On a cold cluster boot this pod can beat dex to serving and gets a
|
||||||
|
// 502 from ingress. Nothing is misconfigured; it simply started first.
|
||||||
|
// Exiting there meant CrashLoopBackOff redoing the backfill on every
|
||||||
|
// attempt, with exponential backoff stretching the brain outage.
|
||||||
|
// - Constructing it first means a genuine config error (wrong issuer,
|
||||||
|
// bad audience) fails in seconds instead of after all that work.
|
||||||
|
//
|
||||||
|
// The budget stays bounded: retrying forever would turn a misconfiguration
|
||||||
|
// into a process that looks alive and never serves.
|
||||||
|
jwtValidator, err := startupretry.Do(ctx,
|
||||||
|
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
|
||||||
|
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
|
||||||
|
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("oidc discovery not ready, retrying", "err", err)
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if jwtValidator != nil {
|
||||||
|
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
watcher.Start(ctx, watcher.Config{
|
watcher.Start(ctx, watcher.Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
@@ -355,15 +439,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||||
|
|
||||||
|
// Project directories excluded from ingestion entirely, as globs
|
||||||
|
// against the project directory name — e.g.
|
||||||
|
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||||
|
//
|
||||||
|
// This is a veto list, deliberately named as one. It complements the
|
||||||
|
// client-name scrubber rather than replacing it: the scrubber drops
|
||||||
|
// individual TURNS whose content matches, while this never opens a
|
||||||
|
// project's files at all. Sessions working on live company financials
|
||||||
|
// are excluded here because their content is confidential in ways no
|
||||||
|
// pattern reliably recognises, and their learnings are captured
|
||||||
|
// deliberately through the session-close ritual instead.
|
||||||
|
//
|
||||||
|
// Caveat worth knowing: a project directory is derived from the
|
||||||
|
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||||
|
// A session run from ~/dev that happens to read a skipped repo will
|
||||||
|
// NOT be excluded by this. The scrubber remains the control for
|
||||||
|
// content; this is a control for whole workspaces.
|
||||||
|
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||||
|
if len(skipProjects) > 0 {
|
||||||
|
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||||
|
}
|
||||||
|
|
||||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||||
go func() {
|
go func() {
|
||||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||||
SessionsDir: claudeDir,
|
SessionsDir: claudeDir,
|
||||||
Host: host,
|
Host: host,
|
||||||
Interval: interval,
|
Interval: interval,
|
||||||
Sink: sink,
|
Sink: sink,
|
||||||
Cursors: cursorStore,
|
Cursors: cursorStore,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
SkipProjects: skipProjects,
|
||||||
}); err != nil && err != context.Canceled {
|
}); err != nil && err != context.Canceled {
|
||||||
logger.Error("claudewatcher exited", "err", err)
|
logger.Error("claudewatcher exited", "err", err)
|
||||||
}
|
}
|
||||||
@@ -403,44 +511,25 @@ func main() {
|
|||||||
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
// Public routes only; buildMux mounts the authenticated REST API (#149).
|
||||||
mux.HandleFunc("POST /query", h.Query)
|
public := map[string]http.Handler{}
|
||||||
mux.HandleFunc("POST /write", h.Write)
|
|
||||||
mux.HandleFunc("POST /index", h.Index)
|
|
||||||
mux.HandleFunc("POST /ingest", h.Ingest)
|
|
||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
|
||||||
mux.HandleFunc("GET /pending", h.Pending)
|
|
||||||
mux.HandleFunc("POST /promote", h.Promote)
|
|
||||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
|
||||||
if webhookHandler != nil {
|
if webhookHandler != nil {
|
||||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
public["POST /webhooks/brain-sync"] = webhookHandler
|
||||||
}
|
}
|
||||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build jwt validator", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if jwtValidator != nil {
|
|
||||||
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
// configured. Static-Bearer-only deployments leave this empty so
|
// configured. Static-Bearer-only deployments leave this empty so
|
||||||
// clients never see an OAuth challenge.
|
// clients never see an OAuth challenge.
|
||||||
var resourceMetadataURL string
|
var resourceMetadataURL string
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
public["GET /.well-known/oauth-protected-resource"] = http.HandlerFunc(
|
||||||
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
if resourceURL != "" {
|
if resourceURL != "" {
|
||||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
|
||||||
|
|
||||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||||
// tracker to file action items, so it only mounts when Gitea is
|
// tracker to file action items, so it only mounts when Gitea is
|
||||||
@@ -457,22 +546,24 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
auditSink := buildAuditSink(ctx, brainDir, logger)
|
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||||
// The Gitea client also satisfies SummaryWriter (#66): session
|
|
||||||
// summaries are written to mathias/ai-sessions over the same API
|
|
||||||
// token. nil only if a future tracker impl lacks file writes.
|
|
||||||
summaryWriter, _ := tracker.(capture.SummaryWriter)
|
|
||||||
captureSvc := capture.NewService(
|
captureSvc := capture.NewService(
|
||||||
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
|
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
|
||||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
mux.Handle("POST /capture", captureH)
|
public["POST /capture"] = captureH
|
||||||
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||||
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
// WithCapture mutates the same instance, so the tool appears live.
|
// WithCapture mutates the same instance, so the tool appears live.
|
||||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
// hyperguild#97: a governance-file rewrite gets the same trail a
|
||||||
|
// capture already gets, plus an out-of-band alert. Wired here because
|
||||||
|
// brain_edit_root needs the capture plumbing anyway — that is what
|
||||||
|
// re-derives the static principal its gate checks.
|
||||||
|
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
||||||
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
||||||
|
"governance_observer", true)
|
||||||
} else {
|
} else {
|
||||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
}
|
}
|
||||||
@@ -490,9 +581,9 @@ func main() {
|
|||||||
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
|
public["GET /.well-known/oauth-authorization-server"] = http.HandlerFunc(
|
||||||
oauth.MetadataHandler(issuer))
|
oauth.MetadataHandler(issuer))
|
||||||
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
|
public["POST /oauth/token"] = http.HandlerFunc(oauth.TokenHandler(oauth.TokenConfig{
|
||||||
ClientID: oauthID,
|
ClientID: oauthID,
|
||||||
ClientSecret: oauthSecret,
|
ClientSecret: oauthSecret,
|
||||||
AccessToken: mcpToken,
|
AccessToken: mcpToken,
|
||||||
@@ -511,7 +602,17 @@ func main() {
|
|||||||
// real request latency. /metrics itself is excluded from its own
|
// real request latency. /metrics itself is excluded from its own
|
||||||
// observation by registering it on the outer mux (post-wrap).
|
// observation by registering it on the outer mux (post-wrap).
|
||||||
reg := metrics.New()
|
reg := metrics.New()
|
||||||
mux.HandleFunc("GET /metrics", reg.Handler())
|
public["GET /metrics"] = http.HandlerFunc(reg.Handler())
|
||||||
|
|
||||||
|
// The whole router, built in one place so the test of buildMux is a test
|
||||||
|
// of production wiring (PR #152 review). The REST API's credential is the
|
||||||
|
// same as /mcp's.
|
||||||
|
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
|
||||||
|
resourceMetadataURL: resourceMetadataURL}, h, public)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build router", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
logger.Info("metrics endpoint registered", "path", "/metrics")
|
logger.Info("metrics endpoint registered", "path", "/metrics")
|
||||||
|
|
||||||
addr := ":" + port
|
addr := ":" + port
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -36,3 +37,42 @@ func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
|||||||
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
||||||
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- #107: an unconfigured alert must announce itself ----------------------
|
||||||
|
|
||||||
|
func TestAlertingWarnings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
want []string // substrings that must appear across the warnings
|
||||||
|
none bool // true = expect no warnings at all
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// The failure this exists to prevent: two alerts dark in
|
||||||
|
// production, discovered only by firing a real event and watching
|
||||||
|
// nothing arrive (#100).
|
||||||
|
name: "notifier unconfigured names what will not fire",
|
||||||
|
env: map[string]string{},
|
||||||
|
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configured notifier is silent",
|
||||||
|
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
|
||||||
|
none: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := alertingWarnings(func(k string) string { return tt.env[k] })
|
||||||
|
if tt.none {
|
||||||
|
assert.Empty(t, got)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joined := strings.Join(got, " ")
|
||||||
|
for _, w := range tt.want {
|
||||||
|
assert.Contains(t, joined, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// publicRoutes is the complete set of patterns allowed on the outer mux,
|
||||||
|
// outside the REST API's auth (hyperguild#149). Each authenticates itself or is
|
||||||
|
// meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook
|
||||||
|
// (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open).
|
||||||
|
// A REST pattern added here would beat the authenticated catch-all by
|
||||||
|
// specificity and ship open, which is why buildMux refuses anything else.
|
||||||
|
var publicRoutes = map[string]bool{
|
||||||
|
"/mcp": true,
|
||||||
|
"POST /capture": true,
|
||||||
|
"POST /webhooks/brain-sync": true,
|
||||||
|
"GET /.well-known/oauth-protected-resource": true,
|
||||||
|
"GET /.well-known/oauth-authorization-server": true,
|
||||||
|
"POST /oauth/token": true,
|
||||||
|
"GET /metrics": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// restAuth is the credential the REST API checks: the same as /mcp's.
|
||||||
|
type restAuth struct {
|
||||||
|
token string
|
||||||
|
validator *chassisauth.JWTValidator
|
||||||
|
realm string
|
||||||
|
resourceMetadataURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMux is the server's whole router. The REST API is mounted at "/"
|
||||||
|
// behind bearer auth that buildMux constructs itself, so a caller cannot pass
|
||||||
|
// a no-op; public handlers are registered as given, and only for patterns on
|
||||||
|
// the publicRoutes allowlist. It returns an http.Handler, not the
|
||||||
|
// *ServeMux, so nothing can register a route on the result afterwards and
|
||||||
|
// bypass the allowlist.
|
||||||
|
func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) {
|
||||||
|
if auth.token == "" {
|
||||||
|
return nil, errors.New("buildMux: REST auth needs a token")
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
for pattern, handler := range public {
|
||||||
|
if !publicRoutes[pattern] {
|
||||||
|
return nil, fmt.Errorf("buildMux: %q is not an allowed public route", pattern)
|
||||||
|
}
|
||||||
|
mux.Handle(pattern, handler)
|
||||||
|
}
|
||||||
|
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||||
|
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
|
||||||
|
})
|
||||||
|
return mux, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountREST registers the REST API on its own mux and mounts it at "/" behind
|
||||||
|
// auth: anything not registered as public lands here and is authenticated,
|
||||||
|
// including routes added later.
|
||||||
|
func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) {
|
||||||
|
rest := http.NewServeMux()
|
||||||
|
rest.HandleFunc("POST /query", h.Query)
|
||||||
|
rest.HandleFunc("POST /write", h.Write)
|
||||||
|
rest.HandleFunc("POST /index", h.Index)
|
||||||
|
rest.HandleFunc("POST /ingest", h.Ingest)
|
||||||
|
rest.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
|
rest.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
|
rest.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
rest.HandleFunc("GET /pending", h.Pending)
|
||||||
|
rest.HandleFunc("POST /promote", h.Promote)
|
||||||
|
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
|
rest.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
|
mux.Handle("/", auth(rest))
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
const routesTok = "routes-test-token"
|
||||||
|
|
||||||
|
func okHandler(body string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// testMux builds the router exactly as main does: buildMux with the full
|
||||||
|
// public set main registers (stub handlers) and real bearer config.
|
||||||
|
// hyperguild#149: the REST API is authenticated as a whole; the PR #152
|
||||||
|
// review showed a test of a COPY of the router let main.go regress unseen.
|
||||||
|
func testMux(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||||
|
public := map[string]http.Handler{}
|
||||||
|
for p := range publicRoutes {
|
||||||
|
public[p] = okHandler("public " + p)
|
||||||
|
}
|
||||||
|
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMuxRefusesRESTRoutesWithoutAValidToken(t *testing.T) {
|
||||||
|
r := testMux(t)
|
||||||
|
for _, rt := range []struct{ method, path string }{
|
||||||
|
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
||||||
|
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
||||||
|
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
||||||
|
} {
|
||||||
|
code, _ := do(t, r, rt.method, rt.path, "")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, code, "%s %s without a token", rt.method, rt.path)
|
||||||
|
code, _ = do(t, r, rt.method, rt.path, "Bearer wrong")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, code, "%s %s with a wrong token", rt.method, rt.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMuxServesRESTRoutesToAValidToken(t *testing.T) {
|
||||||
|
r := testMux(t)
|
||||||
|
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
|
||||||
|
code, _ := do(t, testMux(t), "POST", "/some-future-route", "")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMux registers public handlers as given, without adding auth: each
|
||||||
|
// public route owns its own (bearer on /mcp, HMAC on the webhook, client
|
||||||
|
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
|
||||||
|
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
|
||||||
|
r := testMux(t)
|
||||||
|
for p := range publicRoutes {
|
||||||
|
method, path, ok := strings.Cut(p, " ")
|
||||||
|
if !ok {
|
||||||
|
method, path = "GET", p
|
||||||
|
}
|
||||||
|
code, body := do(t, r, method, path, "")
|
||||||
|
assert.Equal(t, http.StatusOK, code, p)
|
||||||
|
assert.Equal(t, "public "+p, body, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The allowlist is the control: a REST pattern registered as public would win
|
||||||
|
// over the authenticated catch-all (more specific pattern), so it is refused.
|
||||||
|
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
|
||||||
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||||
|
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
|
||||||
|
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
|
||||||
|
assert.Error(t, err, "public pattern %q must be refused", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
|
||||||
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||||
|
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startupSymlinkCheck is the hyperguild#157 tripwire at boot: a symlink in the
|
||||||
|
// brain dir would let brain readers serve a file from outside the brain, so
|
||||||
|
// the server does not start until it is removed. A directory the scan cannot
|
||||||
|
// read could hide one, so it refuses on that too. A missing brain dir (first
|
||||||
|
// boot) is the only case it lets pass.
|
||||||
|
func startupSymlinkCheck(brainDir string) error {
|
||||||
|
r, err := symlinkguard.Scan(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refusing to start: cannot scan the brain dir %s (hyperguild#157): %w", brainDir, err)
|
||||||
|
}
|
||||||
|
if !r.Clean() {
|
||||||
|
return fmt.Errorf("refusing to start: brain dir %s has %s (hyperguild#157); fix them first", brainDir, r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// symlinkScanInterval reads BRAIN_SYMLINK_SCAN_INTERVAL (seconds), falling
|
||||||
|
// back to the default on an unset, non-numeric or non-positive value.
|
||||||
|
func symlinkScanInterval(getenv func(string) string) time.Duration {
|
||||||
|
if s, err := strconv.Atoi(getenv("BRAIN_SYMLINK_SCAN_INTERVAL")); err == nil && s > 0 {
|
||||||
|
return time.Duration(s) * time.Second
|
||||||
|
}
|
||||||
|
return symlinkguard.DefaultInterval
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#157: the server refuses to start while a symlink exists in the
|
||||||
|
// brain dir, or while any part of it cannot be scanned (which could hide
|
||||||
|
// one); a missing brain dir is the only thing it lets pass.
|
||||||
|
func TestStartupSymlinkCheckRefusesABrainWithASymlink(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
|
||||||
|
err := startupSymlinkCheck(brain)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "planted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupSymlinkCheckRefusesABrainWithAnUnreadableDir(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root reads mode-000 directories")
|
||||||
|
}
|
||||||
|
brain := t.TempDir()
|
||||||
|
locked := filepath.Join(brain, "aaa")
|
||||||
|
require.NoError(t, os.MkdirAll(locked, 0o755))
|
||||||
|
require.NoError(t, os.Chmod(locked, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
||||||
|
err := startupSymlinkCheck(brain)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "aaa")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupSymlinkCheckPassesACleanOrMissingBrain(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("x"), 0o644))
|
||||||
|
assert.NoError(t, startupSymlinkCheck(brain))
|
||||||
|
assert.NoError(t, startupSymlinkCheck(filepath.Join(brain, "missing")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSymlinkScanIntervalFallsBackOnNonPositive(t *testing.T) {
|
||||||
|
for _, v := range []string{"0", "-5", "", "junk"} {
|
||||||
|
got := symlinkScanInterval(func(string) string { return v })
|
||||||
|
assert.Positive(t, got, "env %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertingWarningsNamesTheSymlinkTripwire(t *testing.T) {
|
||||||
|
w := strings.Join(alertingWarnings(func(string) string { return "" }), " ")
|
||||||
|
assert.Contains(t, w, "symlink tripwire")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root lists mode-0100 directories")
|
||||||
|
}
|
||||||
|
brain := t.TempDir()
|
||||||
|
require.NoError(t, os.Chmod(brain, 0o100))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
|
||||||
|
assert.Error(t, startupSymlinkCheck(brain))
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
// Aliased: this package's own frontmatter type (below) would collide
|
||||||
|
// with the imported package's name otherwise.
|
||||||
|
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||||
|
)
|
||||||
|
|
||||||
// frontmatter is an ordered, line-preserving view of a note's YAML
|
// frontmatter is an ordered, line-preserving view of a note's YAML
|
||||||
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
||||||
@@ -25,21 +31,37 @@ type fmLine struct {
|
|||||||
// frontmatter block is recognised only when the file opens with a `---`
|
// frontmatter block is recognised only when the file opens with a `---`
|
||||||
// fence and a closing `---` fence follows. Otherwise the whole input is
|
// fence and a closing `---` fence follows. Otherwise the whole input is
|
||||||
// the body and the returned frontmatter is empty.
|
// the body and the returned frontmatter is empty.
|
||||||
|
//
|
||||||
|
// Fence detection itself is frontmatter.Split (hyperguild#138) — this
|
||||||
|
// package's own contribution is purely the ordered, line-preserving fmLine
|
||||||
|
// shape below, which a shared parser has no business owning since it's
|
||||||
|
// specific to how brain_update does a surgical, byte-round-trip edit.
|
||||||
func parseFrontmatter(src string) (frontmatter, string) {
|
func parseFrontmatter(src string) (frontmatter, string) {
|
||||||
var fm frontmatter
|
var fm frontmatter
|
||||||
if !strings.HasPrefix(src, "---\n") {
|
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
||||||
|
if !ok {
|
||||||
return fm, src
|
return fm, src
|
||||||
}
|
}
|
||||||
rest := src[len("---\n"):]
|
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
||||||
end := strings.Index(rest, "\n---\n")
|
// package's always has: block keeps the "\n" immediately before the
|
||||||
if end < 0 {
|
// closing fence, and rest keeps the "\n" immediately after it (both by
|
||||||
// Opening fence with no closing fence — treat as bodyless content.
|
// design, for Fix's own reconstruction). This package's block/body
|
||||||
return fm, src
|
// never included either — TrimSuffix/TrimPrefix restore that exact
|
||||||
}
|
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
||||||
block := rest[:end]
|
// both trims are always exactly one byte, never ambiguous.
|
||||||
body := rest[end+len("\n---\n"):]
|
block := strings.TrimSuffix(string(blockBytes), "\n")
|
||||||
|
body := strings.TrimPrefix(string(restBytes), "\n")
|
||||||
|
|
||||||
for _, line := range strings.Split(block, "\n") {
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
// An indented line is a continuation (a YAML block-list item, a
|
||||||
|
// wrapped value) never a top-level key, regardless of whether it
|
||||||
|
// happens to contain a colon — " - 'Chapter 3: The Reckoning'" is
|
||||||
|
// not a key named "- 'Chapter 3" (hyperguild#128). Checked before
|
||||||
|
// Cut so a colon inside one never reaches the key/value path.
|
||||||
|
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||||
|
fm.lines = append(fm.lines, fmLine{raw: line})
|
||||||
|
continue
|
||||||
|
}
|
||||||
key, val, ok := strings.Cut(line, ":")
|
key, val, ok := strings.Cut(line, ":")
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
@@ -83,9 +105,19 @@ func (f *frontmatter) render() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("---\n")
|
b.WriteString("---\n")
|
||||||
for _, l := range f.lines {
|
for _, l := range f.lines {
|
||||||
if l.key == "" {
|
switch {
|
||||||
|
case l.key == "":
|
||||||
b.WriteString(l.raw)
|
b.WriteString(l.raw)
|
||||||
} else {
|
case l.value == "":
|
||||||
|
// A key with nothing after the colon (the YAML block-list
|
||||||
|
// shape: "aliases:" followed by indented "- item" lines on
|
||||||
|
// their own raw lines) must stay bare — "key: " with a
|
||||||
|
// trailing space is a different byte sequence than the
|
||||||
|
// original, and this type's whole purpose (see the package
|
||||||
|
// doc) is a byte-preserving round-trip.
|
||||||
|
b.WriteString(l.key)
|
||||||
|
b.WriteByte(':')
|
||||||
|
default:
|
||||||
b.WriteString(l.key)
|
b.WriteString(l.key)
|
||||||
b.WriteString(": ")
|
b.WriteString(": ")
|
||||||
b.WriteString(l.value)
|
b.WriteString(l.value)
|
||||||
|
|||||||
@@ -59,3 +59,37 @@ func TestFrontmatterRenderRoundTrips(t *testing.T) {
|
|||||||
fm, _ := parseFrontmatter(src)
|
fm, _ := parseFrontmatter(src)
|
||||||
assert.Equal(t, src, fm.render())
|
assert.Equal(t, src, fm.render())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFrontmatterIndentedListItemWithColonIsNotMisparsedAsKey(t *testing.T) {
|
||||||
|
// hyperguild#128: an indented YAML block-list item that happens to
|
||||||
|
// contain a colon (e.g. a title with a subtitle) is a continuation
|
||||||
|
// line, never a top-level key — strings.Cut doesn't know that just
|
||||||
|
// because TrimSpace(key) happens to be non-empty. Misparsing it as a
|
||||||
|
// key/value pair and re-rendering strips its indentation, de-indenting
|
||||||
|
// it out of the list it belongs to. Same bug class already found and
|
||||||
|
// fixed in the sibling internal/wiki package's parseFrontmatterBlock
|
||||||
|
// (hyperguild#126 review round 3) — this package was the original the
|
||||||
|
// reimplementation was modeled on and had the gap too.
|
||||||
|
src := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\nbody\n"
|
||||||
|
fm, _ := parseFrontmatter(src)
|
||||||
|
fm.set("updated_at", "2026-09-23T00:00:00Z")
|
||||||
|
|
||||||
|
out := fm.render()
|
||||||
|
assert.Contains(t, out, " - 'Chapter 3: The Reckoning'",
|
||||||
|
"the indented list item must survive verbatim, not get re-emitted as a de-indented fake key")
|
||||||
|
assert.NotContains(t, out, "\n- 'Chapter 3",
|
||||||
|
"must not be split into a fake key/value pair at column 0 (as opposed to surviving indented, asserted above)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterRenderEmptyValueKeyStaysBareNoTrailingSpace(t *testing.T) {
|
||||||
|
// Companion to the indented-list-item fix: "aliases:" (a key with
|
||||||
|
// nothing after the colon, the YAML block-list header) must round-trip
|
||||||
|
// bare, not as "aliases: " with a trailing space — a different byte
|
||||||
|
// sequence than the original, breaking this type's whole reason for
|
||||||
|
// being a line-preserving view rather than a YAML re-marshal.
|
||||||
|
// render() returns only the frontmatter block (see
|
||||||
|
// TestFrontmatterRenderRoundTrips), no trailing body.
|
||||||
|
src := "---\naliases:\n - one\n - two\n---\n"
|
||||||
|
fm, _ := parseFrontmatter(src)
|
||||||
|
assert.Equal(t, src, fm.render())
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
@@ -53,8 +53,6 @@ type queryRequest struct {
|
|||||||
type writeRequest struct {
|
type writeRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Domain string `json:"domain,omitempty"`
|
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
||||||
@@ -109,20 +107,21 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// WriteNoteOptions configures how a brain note is written.
|
// WriteNoteOptions configures how a brain note is written.
|
||||||
//
|
//
|
||||||
// When both Wing and Hall are non-empty, the note routes into the
|
// Wing and Hall are both required: the note routes into the structured
|
||||||
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
// wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets wing/hall/created_at
|
||||||
// wing/hall/created_at injected into its YAML frontmatter.
|
// injected into its YAML frontmatter. There is no other valid destination
|
||||||
//
|
// (the legacy brain/knowledge/ fallback was removed — ADR-0012 / #79).
|
||||||
// When either is empty, the note falls back to brain/knowledge/<filename>
|
|
||||||
// with optional type/domain frontmatter (legacy behaviour).
|
|
||||||
type WriteNoteOptions struct {
|
type WriteNoteOptions struct {
|
||||||
Content string
|
Content string
|
||||||
Filename string
|
Filename string
|
||||||
Type string
|
|
||||||
Domain string
|
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
||||||
|
// Origin/WrittenBy are server-owned provenance (hyperguild#145): any
|
||||||
|
// origin/written_by in Content's own frontmatter is discarded, so a
|
||||||
|
// caller cannot claim a more trusted origin than it authenticated as.
|
||||||
|
Origin string
|
||||||
|
WrittenBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteNote writes a markdown note into the brain. Returns the path
|
// WriteNote writes a markdown note into the brain. Returns the path
|
||||||
@@ -131,14 +130,21 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if opts.Content == "" {
|
if opts.Content == "" {
|
||||||
return "", fmt.Errorf("content is required")
|
return "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
if opts.Wing == "" || opts.Hall == "" {
|
||||||
|
return "", fmt.Errorf("wing and hall are required")
|
||||||
|
}
|
||||||
|
|
||||||
if opts.Wing != "" && opts.Hall != "" {
|
// In the primitive, not in a caller: POST /write reaches here directly,
|
||||||
return writeHallNote(brainDir, opts)
|
// without the BrainStore, and a gate one door wide is not a gate.
|
||||||
|
// BrainStore still calls the strip itself to report Ref.Unlinked; the
|
||||||
|
// second pass is a no-op on already-clean content.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
if opts.Wing != "" || opts.Hall != "" {
|
opts.Content = clean
|
||||||
return "", fmt.Errorf("wing and hall must be set together")
|
|
||||||
}
|
return writeHallNote(brainDir, opts)
|
||||||
return writeLegacyNote(brainDir, opts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
||||||
@@ -152,20 +158,46 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
existingFields, body := splitFrontmatter(opts.Content)
|
||||||
return "", fmt.Errorf("create hall dir: %w", err)
|
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||||
|
for _, f := range existingFields {
|
||||||
|
existingByKey[f.key] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the claim-property envelope BEFORE creating the directory, so a
|
||||||
|
// rejected write leaves nothing behind — not even an empty hall dir
|
||||||
|
// (brain#21). Fail closed: a value outside the enum is worse than an absent
|
||||||
|
// one, because a lint skips what it cannot parse and the note then reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
|
||||||
|
return "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Type != "" {
|
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
|
||||||
}
|
// writeField merges one key: opts.Content's own value (if the note already
|
||||||
if opts.Domain != "" {
|
// carries this field in its own frontmatter) always wins over the fallback,
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
// so promotion/extraction-step metadata survives verbatim instead of being
|
||||||
|
// shadowed by a second, stacked frontmatter block (#86).
|
||||||
|
writeField := func(key, fallback string) {
|
||||||
|
emitted[key] = true
|
||||||
|
if f, ok := existingByKey[key]; ok {
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fallback != "" {
|
||||||
|
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sourceType := opts.SourceType
|
sourceType := opts.SourceType
|
||||||
if sourceType == "" && opts.Hall == "facts" {
|
if sourceType == "" && opts.Hall == "facts" {
|
||||||
@@ -175,81 +207,124 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
// citation-needing entry (brain-gardener#7).
|
// citation-needing entry (brain-gardener#7).
|
||||||
sourceType = "internal"
|
sourceType = "internal"
|
||||||
}
|
}
|
||||||
if sourceType != "" {
|
writeField("source_type", sourceType)
|
||||||
fmt.Fprintf(&fm, "source_type: %s\n", sourceType)
|
|
||||||
|
for _, f := range existingFields {
|
||||||
|
if emitted[f.key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// The directory is created only once the note is known to be writable:
|
||||||
|
// a refused write leaves nothing behind, not even an empty hall dir
|
||||||
|
// (brain#21; provenance refusal joins the envelope refusal here).
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write: %w", err)
|
return "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(brainDir, dest)
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
return filepath.ToSlash(rel), nil
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
// scalarValues flattens frontmatter fields to key -> scalar value for
|
||||||
// callers that have not adopted the wing/hall taxonomy.
|
// validation. Multi-line values (lists, nested blocks) yield the text after
|
||||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
// the colon on the first line, which is empty for a list — correct here,
|
||||||
filename := opts.Filename
|
// since every claim-property field is a scalar enum.
|
||||||
if filename == "" {
|
func scalarValues(fields []frontmatterField) map[string]string {
|
||||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
out := make(map[string]string, len(fields))
|
||||||
}
|
for _, f := range fields {
|
||||||
|
if len(f.lines) == 0 {
|
||||||
rawDir := filepath.Join(brainDir, "knowledge")
|
continue
|
||||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
|
||||||
return "", fmt.Errorf("create raw dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalContent := opts.Content
|
|
||||||
if opts.Type != "" || opts.Domain != "" {
|
|
||||||
var fm strings.Builder
|
|
||||||
fm.WriteString("---\n")
|
|
||||||
if opts.Type != "" {
|
|
||||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
|
||||||
}
|
}
|
||||||
if opts.Domain != "" {
|
_, value, found := strings.Cut(f.lines[0], ":")
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
if !found {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
out[f.key] = strings.TrimSpace(value)
|
||||||
finalContent = fm.String() + opts.Content
|
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
if strings.ContainsAny(filename, `/\`) {
|
|
||||||
return "", fmt.Errorf("invalid filename")
|
|
||||||
}
|
|
||||||
base := filepath.Base(filename)
|
|
||||||
if base == "." || base == ".." || base == "" {
|
|
||||||
return "", fmt.Errorf("invalid filename")
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(base, ".md") {
|
|
||||||
base += ".md"
|
|
||||||
}
|
|
||||||
dest := filepath.Join(rawDir, base)
|
|
||||||
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
|
||||||
return "", fmt.Errorf("write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rel, _ := filepath.Rel(brainDir, dest)
|
|
||||||
return filepath.ToSlash(rel), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write handles POST /write — write raw content to brain/knowledge/.
|
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||||
|
// along with its raw line and any indented continuation lines (e.g. a
|
||||||
|
// bulleted list value spanning multiple lines).
|
||||||
|
type frontmatterField struct {
|
||||||
|
key string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of
|
||||||
|
// content, returning its top-level fields in original order and the
|
||||||
|
// remaining body. If content has no leading frontmatter block, fields is
|
||||||
|
// nil and body is content unchanged.
|
||||||
|
func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
||||||
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
|
return nil, content
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
i := 1
|
||||||
|
var cur *frontmatterField
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
key, _, _ := strings.Cut(line, ":")
|
||||||
|
cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}}
|
||||||
|
} else if cur != nil {
|
||||||
|
cur.lines = append(cur.lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
body = strings.Join(lines[i:], "\n")
|
||||||
|
return fields, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write handles POST /write — write a note into brain/wiki/<wing>/<hall>/.
|
||||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||||
var req writeRequest
|
var req writeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
// Field by field, not a struct conversion: provenance is server-owned and
|
||||||
|
// this door carries no principal, so it stamps origin: unknown
|
||||||
|
// (hyperguild#145). A JSON body must never be able to set it.
|
||||||
|
relPath, err := WriteNote(h.brainDir, WriteNoteOptions{
|
||||||
|
Content: req.Content,
|
||||||
|
Filename: req.Filename,
|
||||||
|
Wing: req.Wing,
|
||||||
|
Hall: req.Hall,
|
||||||
|
SourceType: req.SourceType,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Wing != "" && req.Hall != "" {
|
// WriteNote errors out above unless both are set, so this always runs —
|
||||||
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
// no legacy fallback path left to guard against.
|
||||||
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||||
}
|
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]string{"path": relPath})
|
writeJSON(w, map[string]string{"path": relPath})
|
||||||
}
|
}
|
||||||
@@ -345,11 +420,19 @@ func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedExtensions lists file extensions that IngestPath will process.
|
// isSupportedExtension reports whether IngestPath will process ext.
|
||||||
var supportedExtensions = map[string]bool{
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg require docmark (ADR-0013) and are only
|
||||||
".md": true,
|
// supported when DOCMARK_URL is configured — checked per-call (not cached at
|
||||||
".txt": true,
|
// package init) so it reflects the environment at request time.
|
||||||
".pdf": true,
|
func isSupportedExtension(ext string) bool {
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt", ".pdf":
|
||||||
|
return true
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return os.Getenv("DOCMARK_URL") != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||||
@@ -363,6 +446,12 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "path is required")
|
writeError(w, http.StatusBadRequest, "path is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolved, err := ResolveIngestPath(h.brainDir, req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Path = resolved
|
||||||
|
|
||||||
info, err := os.Stat(req.Path)
|
info, err := os.Stat(req.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -382,10 +471,16 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content, readErr := extract.Text(path)
|
// Each walked file is re-resolved: a symlinked file inside raw/
|
||||||
|
// would otherwise lead extract.Text out of the inbox (#149).
|
||||||
|
if _, rerr := ResolveIngestPath(h.brainDir, path); rerr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, readErr := ReadIngestFile(h.brainDir, path)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||||
return nil
|
return nil
|
||||||
@@ -410,11 +505,11 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content, readErr := extract.Text(req.Path)
|
content, readErr := ReadIngestFile(h.brainDir, req.Path)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
|
||||||
return
|
return
|
||||||
@@ -466,7 +561,8 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
// No principal on this door: pages are stamped origin: unknown (#145).
|
||||||
|
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "ingest error")
|
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||||
@@ -522,7 +618,11 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
// Field by field: this door carries no principal, so the promoted note is
|
||||||
|
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||||
|
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||||
|
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
|||||||
assert.NotEmpty(t, results)
|
assert.NotEmpty(t, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
func TestWrite_CreatesWikiFile(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"content": "# Test note\n\nSome content.",
|
"content": "# Test note\n\nSome content.",
|
||||||
"filename": "test-note.md",
|
"filename": "test-note",
|
||||||
|
"wing": "homelab",
|
||||||
|
"hall": "facts",
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -81,11 +83,25 @@ func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
assert.NotEmpty(t, resp["path"])
|
assert.NotEmpty(t, resp["path"])
|
||||||
|
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
|
content, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "test-note.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "Some content.")
|
assert.Contains(t, string(content), "Some content.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWrite_RequiresWingAndHall is the HTTP-contract counterpart of
|
||||||
|
// TestWriteNote_RequiresWingAndHall: a caller (e.g. the MCP brain_write
|
||||||
|
// handler) that omits wing/hall gets a 400, not a silent legacy write.
|
||||||
|
func TestWrite_RequiresWingAndHall(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "no destination"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Write(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestQuery_RequiresQuery(t *testing.T) {
|
func TestQuery_RequiresQuery(t *testing.T) {
|
||||||
_, h := setup(t)
|
_, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"limit": 5})
|
body, _ := json.Marshal(map[string]any{"limit": 5})
|
||||||
@@ -97,27 +113,6 @@ func TestQuery_RequiresQuery(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
|
||||||
dir, h := setup(t)
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
|
||||||
"content": "Some learning.",
|
|
||||||
"filename": "typed-note.md",
|
|
||||||
"type": "concept",
|
|
||||||
"domain": "software",
|
|
||||||
})
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.Write(rec, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(content), "type: concept")
|
|
||||||
assert.Contains(t, string(content), "domain: software")
|
|
||||||
assert.Contains(t, string(content), "Some learning.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -126,7 +121,6 @@ func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
|||||||
Filename: "session-koala-abc",
|
Filename: "session-koala-abc",
|
||||||
Wing: "claude-sessions",
|
Wing: "claude-sessions",
|
||||||
Hall: "facts",
|
Hall: "facts",
|
||||||
Type: "source",
|
|
||||||
SourceType: "internal",
|
SourceType: "internal",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -186,19 +180,119 @@ func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) {
|
|||||||
assert.NotContains(t, string(got), "source_type")
|
assert.NotContains(t, string(got), "source_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n",
|
||||||
|
Filename: "act-runner-host-executor",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "failures",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(got)
|
||||||
|
|
||||||
|
// exactly one frontmatter block: only two "---" delimiter lines total
|
||||||
|
assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks")
|
||||||
|
assert.Contains(t, body, "wing: homelab")
|
||||||
|
assert.Contains(t, body, "hall: failures")
|
||||||
|
assert.Contains(t, body, "title: act_runner host-executor")
|
||||||
|
assert.Contains(t, body, "tags: [gitea-actions, act_runner]")
|
||||||
|
assert.Contains(t, body, "# Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract
|
||||||
|
// (hyperguild#79 / ADR-0012): with the knowledge/ fallback gone, wing+hall
|
||||||
|
// is the only valid destination, so omitting either is a caller error, not
|
||||||
|
// a silent fallback.
|
||||||
|
func TestWriteNote_RequiresWingAndHall(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
wing string
|
||||||
|
hall string
|
||||||
|
}{
|
||||||
|
{"neither set", "", ""},
|
||||||
|
{"wing only", "homelab", ""},
|
||||||
|
{"hall only", "", "facts"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "body",
|
||||||
|
Wing: tc.wing,
|
||||||
|
Hall: tc.hall,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "wing and hall are required")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "auto name",
|
||||||
|
"wing": "homelab",
|
||||||
|
"hall": "facts",
|
||||||
|
})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
entries, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts"))
|
||||||
// +1 because setup already wrote tdd.md
|
require.NoError(t, err)
|
||||||
assert.Len(t, entries, 2)
|
require.Len(t, entries, 1)
|
||||||
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
|
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a
|
||||||
|
// hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON
|
||||||
|
// envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename,
|
||||||
|
// content}. cmd/hyperguild lives in a separate Go module from ingestion
|
||||||
|
// (no shared go.work), so a Go-level test can't import brainClient
|
||||||
|
// directly — this test instead pins the wire contract from the server
|
||||||
|
// side and proves a real `hyperguild brain write <wing> <hall> <slug>`
|
||||||
|
// invocation writes a real file with real frontmatter under wiki/<wing>/<hall>/.
|
||||||
|
// A prior version of this contract test on the client side (in
|
||||||
|
// cmd/hyperguild/http_test.go) used only an httptest.NewServer with a
|
||||||
|
// hand-rolled mock body, so it stayed green through a regression where the
|
||||||
|
// server required wing/hall but the client never sent them (hyperguild#79
|
||||||
|
// PR review).
|
||||||
|
func TestWrite_CLIPayloadRoundTrips(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(h.Write))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var out struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||||
|
assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path)
|
||||||
|
|
||||||
|
written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(written), "wing: homelab")
|
||||||
|
assert.Contains(t, string(written), "hall: facts")
|
||||||
|
assert.Contains(t, string(written), "Real write.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -267,10 +361,11 @@ func TestIngestPath_MissingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_File(t *testing.T) {
|
func TestIngestPath_File(t *testing.T) {
|
||||||
_, h := setup(t)
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
// Create a temp file with content
|
// Create a temp file with content
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
f := filepath.Join(dir, "doc.md")
|
f := filepath.Join(dir, "doc.md")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
||||||
|
|
||||||
@@ -374,10 +469,11 @@ func TestIngestRaw_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_Directory(t *testing.T) {
|
func TestIngestPath_Directory(t *testing.T) {
|
||||||
_, h := setup(t)
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
// Create a temp dir with one .md file
|
// Create a temp dir with one .md file
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
@@ -398,3 +494,102 @@ func TestIngestPath_Directory(t *testing.T) {
|
|||||||
require.True(t, ok, "pages must be an array")
|
require.True(t, ok, "pages must be an array")
|
||||||
assert.NotEmpty(t, pagesSlice)
|
assert.NotEmpty(t, pagesSlice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- brain#21: claim-property envelope on the write path -------------------
|
||||||
|
|
||||||
|
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
|
||||||
|
// writeHallNote already passes unknown keys through; this pins that
|
||||||
|
// behaviour for the envelope specifically, so a future refactor of the
|
||||||
|
// merge loop cannot silently drop it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "half_life: durable")
|
||||||
|
assert.Contains(t, string(got), "applies: universal")
|
||||||
|
assert.Contains(t, string(got), "claim_provenance: asserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// Fail closed: a value outside the enum must not land. A lint skips what
|
||||||
|
// it cannot parse, so an unvalidated write produces a note that reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
|
||||||
|
Filename: "bad-envelope",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "half_life")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\napplies: homelab\n---\n\nBody.\n",
|
||||||
|
Filename: "no-provenance",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim_provenance")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HTTP POST /write door reaches WriteNote directly, without the
|
||||||
|
// BrainStore, so the unresolved-wikilink strip has to live in the primitive
|
||||||
|
// or that door stays ungated.
|
||||||
|
func TestWriteNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "See [[target]] and [[gitea-ci]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and gitea-ci.")
|
||||||
|
assert.NotContains(t, string(got), "[[gitea-ci]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, _, _, err := api.UpdateNote(dir, api.UpdateNoteOptions{
|
||||||
|
Wing: "a", Hall: "facts", Slug: "n",
|
||||||
|
Content: "Related: [[secret-hygiene]].\n", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// ingestion/internal/api/ingestpath_docmark_test.go
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestPath_DocxUnsupportedWhenDocmarkNotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"# Converted Doc"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.NotEmpty(t, pages)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrOutsideIngestRoot is the one refusal for every bad ingest path. It never
|
||||||
|
// carries the underlying error: "no such file" versus "permission denied"
|
||||||
|
// would let a caller probe which server files exist (PR #152 review).
|
||||||
|
var ErrOutsideIngestRoot = errors.New("ingest path must be an existing file or directory inside brain/raw/")
|
||||||
|
|
||||||
|
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
|
||||||
|
// inbox the watcher already owns (hyperguild#149). Relative paths resolve
|
||||||
|
// against that root; symlinks are evaluated before the containment check, so
|
||||||
|
// a link inside raw/ that points elsewhere is refused. The path must exist.
|
||||||
|
func ResolveIngestPath(brainDir, p string) (string, error) {
|
||||||
|
if strings.TrimSpace(p) == "" {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
// raw/ itself must be a real directory: if it were a symlink, containment
|
||||||
|
// inside it would mean nothing (hyperguild#153 item 4).
|
||||||
|
if fi, lerr := os.Lstat(filepath.Join(brainDir, "raw")); lerr != nil || fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(root, p)
|
||||||
|
}
|
||||||
|
real, err := filepath.EvalSymlinks(filepath.Clean(p))
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, real)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
return real, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadIngestFile extracts text from an ingest path already confined by
|
||||||
|
// ResolveIngestPath, reading it through the raw/ root rather than reopening
|
||||||
|
// the path by name (hyperguild#153 item 3): if the file is swapped for a
|
||||||
|
// symlink after the check, the read still cannot leave raw/.
|
||||||
|
func ReadIngestFile(brainDir, resolved string) (string, error) {
|
||||||
|
root, rawPath, err := OpenRawRoot(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
realRoot, err := filepath.EvalSymlinks(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(realRoot, resolved)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
return extract.TextIn(root, rel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: /ingest-path and MCP brain_ingest read a caller-chosen
|
||||||
|
// server path, so any file the pod could read (the ServiceAccount token,
|
||||||
|
// /etc/passwd) could be pulled into the brain and read back out. Ingest paths
|
||||||
|
// now resolve inside brain/raw/ — the inbox the watcher already owns — with
|
||||||
|
// symlinks evaluated, or are refused.
|
||||||
|
func TestResolveIngestPathConfinesToRawInbox(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
raw := filepath.Join(brain, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(raw, "sub"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "sub", "ok.md"), []byte("x"), 0o644))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("s"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "link.md")))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(raw, "dirlink")))
|
||||||
|
|
||||||
|
for _, ok := range []string{filepath.Join(raw, "sub", "ok.md"), "sub/ok.md", raw, "sub"} {
|
||||||
|
_, err := api.ResolveIngestPath(brain, ok)
|
||||||
|
assert.NoError(t, err, ok)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
"/etc/hostname", outside, "../secret.txt", filepath.Join(raw, "..", "wiki"),
|
||||||
|
filepath.Join(raw, "link.md"), filepath.Join(raw, "dirlink", "secret.txt"),
|
||||||
|
raw + "-evil/x.md", "",
|
||||||
|
} {
|
||||||
|
_, err := api.ResolveIngestPath(brain, bad)
|
||||||
|
assert.Error(t, err, "must refuse %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPathRefusesAPathOutsideTheInbox(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": "/etc/hostname"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "raw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory ingest must not follow a symlinked FILE inside raw/ out of it.
|
||||||
|
func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
raw := filepath.Join(dir, "raw", "batch")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\nSENTINEL-149-OUTSIDE"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "leak.md")))
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": "batch", "source": "walk"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||||
|
assert.Contains(t, rec.Body.String(), "skipped")
|
||||||
|
assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR #152 review: the raw EvalSymlinks error told an authenticated caller
|
||||||
|
// whether any server file existed ("no such file") or was unreadable
|
||||||
|
// ("permission denied"), and leaked the brain directory. Every refusal now
|
||||||
|
// reads the same.
|
||||||
|
func TestResolveIngestPathRefusalsAreIndistinguishable(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
|
||||||
|
var msgs []string
|
||||||
|
for _, p := range []string{"/etc/hostname", "/etc/definitely-not-here-149", "../x", "nope/missing.md", ""} {
|
||||||
|
_, err := api.ResolveIngestPath(brain, p)
|
||||||
|
require.Error(t, err, p)
|
||||||
|
msgs = append(msgs, err.Error())
|
||||||
|
assert.NotContains(t, err.Error(), brain, "refusal for %q leaks the brain dir", p)
|
||||||
|
}
|
||||||
|
for _, m := range msgs[1:] {
|
||||||
|
assert.Equal(t, msgs[0], m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -28,25 +30,25 @@ var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
|||||||
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||||
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||||
func ListPending(brainDir string) ([]PendingNote, error) {
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||||
dir := filepath.Join(brainDir, "raw")
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return []PendingNote{}, nil
|
return []PendingNote{}, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
entries, err := fs.ReadDir(root.FS(), ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]PendingNote, 0, len(entries))
|
out := make([]PendingNote, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
continue
|
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||||
}
|
}
|
||||||
info, statErr := e.Info()
|
raw, info, readErr := readRawNote(root, e.Name())
|
||||||
if statErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,10 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||||
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||||
|
Origin string
|
||||||
|
WrittenBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||||
@@ -101,12 +107,9 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
src := filepath.Join(brainDir, "raw", base)
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
raw, err := os.ReadFile(src)
|
raw, err := readRawSource(brainDir, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return "", err
|
||||||
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collision: never silently overwrite an existing promoted note.
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
@@ -128,7 +131,11 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("create wing dir: %w", err)
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
}
|
}
|
||||||
// Write-then-delete: the source survives any write failure.
|
// Write-then-delete: the source survives any write failure.
|
||||||
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write promoted note: %w", err)
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Remove(src); err != nil {
|
if err := os.Remove(src); err != nil {
|
||||||
@@ -145,6 +152,65 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return relSlash, nil
|
return relSlash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RejectOptions identifies a raw note to reject and why.
|
||||||
|
type RejectOptions struct {
|
||||||
|
Filename string // basename in brain/raw/
|
||||||
|
Reason string // optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||||
|
// without silently losing it: it stamps rejected_at (+ reject_reason when
|
||||||
|
// given) into frontmatter and moves the file to brain/raw/rejected/, using
|
||||||
|
// the same write-then-delete safety PromoteNote uses — the source survives
|
||||||
|
// any write failure — and the same basename-only filename validation.
|
||||||
|
//
|
||||||
|
// A rejected note never entered the wiki, so no index rebuild or
|
||||||
|
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
|
||||||
|
// journey, not the start of a new one. It stays out of ListPending's view
|
||||||
|
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
|
||||||
|
// does not recurse), so a rejected note is never re-surfaced as pending —
|
||||||
|
// the human decision, once made, stays made, the same principle behind
|
||||||
|
// this estate's "issue status is surfaced, not auto-closed, but once a
|
||||||
|
// human decides, the decision is recorded" rule applied one level earlier.
|
||||||
|
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
||||||
|
base := filepath.Base(opts.Filename)
|
||||||
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := readRawSource(brainDir, base)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||||
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if opts.Reason != "" {
|
||||||
|
fm.set("reject_reason", opts.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create rejected dir: %w", err)
|
||||||
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write rejected note: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(src); err != nil {
|
||||||
|
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return filepath.ToSlash(rel), nil
|
||||||
|
}
|
||||||
|
|
||||||
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||||
func excerpt(s string, n int) string {
|
func excerpt(s string, n int) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
@@ -154,3 +220,24 @@ func excerpt(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(r))
|
return strings.TrimSpace(string(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||||
|
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||||
|
func readRawSource(brainDir, base string) ([]byte, error) {
|
||||||
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
raw, _, err := readRawNote(root, base)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,3 +119,85 @@ func TestPromoteRebuildsWingIndex(t *testing.T) {
|
|||||||
require.NoError(t, err, "wing _index regenerated")
|
require.NoError(t, err, "wing _index regenerated")
|
||||||
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RejectNote (hyperguild#81) ---------------------------------------------
|
||||||
|
|
||||||
|
func TestRejectHappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-04-23-fed-sentiment-scrape.md",
|
||||||
|
"---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Sentiment scrape\n\nbody.\n")
|
||||||
|
|
||||||
|
rel, err := RejectNote(dir, RejectOptions{
|
||||||
|
Filename: "2026-04-23-fed-sentiment-scrape.md", Reason: "generic scrape, not homelab-relevant",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "raw/rejected/2026-04-23-fed-sentiment-scrape.md", rel)
|
||||||
|
|
||||||
|
// Source removed from the pending queue.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-04-23-fed-sentiment-scrape.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "source removed from raw/ after reject")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "created_at: 2026-04-23T13:20:07Z", "original created_at preserved")
|
||||||
|
assert.Contains(t, s, "rejected_at:")
|
||||||
|
assert.Contains(t, s, "reject_reason: generic scrape, not homelab-relevant")
|
||||||
|
assert.Contains(t, s, "# Sentiment scrape", "body preserved for later reference")
|
||||||
|
|
||||||
|
// Rejected notes never entered the wiki, and are no longer pending.
|
||||||
|
pending, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectWithoutReason(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
rel, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
assert.NotContains(t, string(got), "reject_reason:", "no reason given, none stamped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectMissingSourceErrors(t *testing.T) {
|
||||||
|
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "ghost.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectRejectsTraversalFilename(t *testing.T) {
|
||||||
|
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "../escape.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectCollisionNoOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "raw", "rejected", "x.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||||
|
writeRaw(t, dir, "x.md", "NEW\n")
|
||||||
|
|
||||||
|
_, err := RejectNote(dir, RejectOptions{Filename: "x.md"})
|
||||||
|
require.Error(t, err, "collision must error, not overwrite")
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "x.md"))
|
||||||
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedNotesAreNotListedAsPendingAgain(t *testing.T) {
|
||||||
|
// ListPending already skips tunnel-candidates-*.md; confirm raw/rejected/
|
||||||
|
// (a subdirectory) doesn't leak into the top-level pending listing either
|
||||||
|
// — os.ReadDir on raw/ doesn't recurse, but pin it so a future change to
|
||||||
|
// ListPending can't silently start surfacing rejected notes as pending
|
||||||
|
// again.
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
_, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pending, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, pending, "a rejected note must not still appear as pending")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StampProvenance applies server-owned provenance (hyperguild#145) to note
|
||||||
|
// content. See package provenance for the rule; an error means the stamped
|
||||||
|
// note would not read back as stamped, and the write must be refused.
|
||||||
|
func StampProvenance(content, origin, writtenBy string) (string, error) {
|
||||||
|
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StampProvenanceFile applies StampProvenance to a brain-relative note path.
|
||||||
|
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
|
||||||
|
_, abs, err := resolveWithin(brainDir, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// REST POST /write carries no principal: it stamps origin: unknown, and no
|
||||||
|
// spelling of provenance in its body reaches the note (hyperguild#145).
|
||||||
|
func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "---\norigin: static\n---\n# R\n\nbody", "filename": "rest-prov", "wing": "homelab", "hall": "facts",
|
||||||
|
"origin": "static", "written_by": "local-cli", "Origin": "static", "WrittenBy": "local-cli",
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Write(rec, httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "rest-prov.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(strings.SplitN(string(b), "---\n", 3)[1]), &m))
|
||||||
|
assert.Equal(t, "unknown", m["origin"])
|
||||||
|
assert.NotContains(t, m, "written_by")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A principal name that is not a bare word is quoted, so it cannot start a
|
||||||
|
// new frontmatter line (e.g. a JWT sub containing a newline).
|
||||||
|
func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
|
||||||
|
out, err := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
|
||||||
|
require.NoError(t, err)
|
||||||
|
fm := strings.SplitN(out, "---\n", 3)[1]
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
|
||||||
|
assert.Equal(t, "jwt", m["origin"])
|
||||||
|
assert.Equal(t, "evil\norigin: static", m["written_by"])
|
||||||
|
assert.Equal(t, 1, strings.Count(fm, "\norigin:")+boolToInt(strings.HasPrefix(fm, "origin:")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
|
||||||
|
out, err := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# no fm"))
|
||||||
|
assert.Contains(t, out, "origin: static in the body is prose", "body lines are never touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 2 (R3/R4): the REST ingest doors write through the pipeline, which
|
||||||
|
// now stamps every page; a title newline cannot forge provenance for the
|
||||||
|
// brain's line-oriented reader.
|
||||||
|
func TestRESTIngestRawStampsUnknownAndTitleNewlineCannotForge(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "zqrest", "pages": []map[string]any{
|
||||||
|
{"title": "Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "zqrestinj body"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.IngestRaw(rec, httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var resp struct{ Pages []string }
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.NotEmpty(t, resp.Pages)
|
||||||
|
for _, p := range resp.Pages {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
fm := strings.SplitN(string(b), "---\n", 3)[1]
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
|
||||||
|
assert.Equal(t, "unknown", m["origin"])
|
||||||
|
assert.NotContains(t, m, "written_by")
|
||||||
|
for _, line := range strings.Split(fm, "\n") {
|
||||||
|
assert.False(t, strings.HasPrefix(line, "origin: static"), "line reader sees forged origin in %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRESTIngestStampsUnknown(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "Some article text about shaping work.", "source": "zqllm"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Ingest(rec, httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var resp struct{ Pages []string }
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.NotEmpty(t, resp.Pages, "stub pipeline must write at least one page")
|
||||||
|
for _, p := range resp.Pages {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, strings.SplitN(string(b), "---\n", 3)[1], "origin: unknown\n", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused write leaves nothing behind, not even an empty hall directory
|
||||||
|
// (brain#21's invariant, round 3 non-blocking finding).
|
||||||
|
func TestWriteNoteRefusedForProvenanceLeavesNoDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# x\n\nbody", Wing: "freshwing", Hall: "facts", Filename: "x", Origin: "jwt", WrittenBy: "bad\xff",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki", "freshwing"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "no wing directory may be created by a refused write")
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#153 / PR #156: behavioural race tests, so the control does not
|
||||||
|
// rest on lints that list forbidden APIs (round 3: an os.ReadFile or
|
||||||
|
// Root.ReadFile rewrite passed the lint and leaked 19k times in 4s). A
|
||||||
|
// swapper keeps renaming a regular note, a symlink to an outside file, or a
|
||||||
|
// FIFO into raw/r.md while the public readers loop. Any leaked byte fails;
|
||||||
|
// any hang fails.
|
||||||
|
|
||||||
|
const raceSentinel = "SENTINEL-153-RACE"
|
||||||
|
|
||||||
|
// startSwapper runs fn until the test ends, and waits for it to exit before
|
||||||
|
// t.TempDir's cleanup removes the directory it writes into (hyperguild#162:
|
||||||
|
// an unawaited swapper raced the cleanup, "directory not empty").
|
||||||
|
func startSwapper(t *testing.T, fn func(stop *atomic.Bool)) {
|
||||||
|
t.Helper()
|
||||||
|
var stop atomic.Bool
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); fn(&stop) }()
|
||||||
|
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func swap(raw, outside string, fifo bool, stop *atomic.Bool) {
|
||||||
|
tgt := filepath.Join(raw, "r.md")
|
||||||
|
for i := 0; !stop.Load(); i++ {
|
||||||
|
tmp := filepath.Join(raw, ".swap")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
switch {
|
||||||
|
case i%2 == 0:
|
||||||
|
_ = os.WriteFile(tmp, []byte("# r\n\ninside"), 0o644)
|
||||||
|
case fifo:
|
||||||
|
_ = syscall.Mkfifo(tmp, 0o644)
|
||||||
|
default:
|
||||||
|
_ = os.Symlink(outside, tmp)
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, tgt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func raceBrain(t *testing.T) (brain, raw, outside string) {
|
||||||
|
t.Helper()
|
||||||
|
brain = t.TempDir()
|
||||||
|
raw = filepath.Join(brain, "raw")
|
||||||
|
if err := os.MkdirAll(raw, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside = filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
if err := os.WriteFile(outside, []byte("# s\n\n"+raceSentinel), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(raw, "r.md"), []byte("# r\n\ninside"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return brain, raw, outside
|
||||||
|
}
|
||||||
|
|
||||||
|
// readers runs every public raw/ reader once and returns all text they
|
||||||
|
// produced (excerpts, extracted text).
|
||||||
|
func readers(brain, raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if ns, err := api.ListPending(brain); err == nil {
|
||||||
|
for _, n := range ns {
|
||||||
|
b.WriteString(n.Excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s, err := api.ReadIngestFile(brain, filepath.Join(raw, "r.md")); err == nil {
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
|
||||||
|
brain, raw, outside := raceBrain(t)
|
||||||
|
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, false, stop) })
|
||||||
|
leaks, iters := 0, 0
|
||||||
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
||||||
|
leaks += strings.Count(readers(brain, raw), raceSentinel)
|
||||||
|
}
|
||||||
|
if leaks > 0 {
|
||||||
|
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
||||||
|
}
|
||||||
|
t.Logf("%d iterations, 0 leaks", iters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
|
||||||
|
brain, raw, outside := raceBrain(t)
|
||||||
|
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, true, stop) })
|
||||||
|
var iters atomic.Int64
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
||||||
|
_ = readers(brain, raw)
|
||||||
|
iters.Add(1)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(6 * time.Second):
|
||||||
|
// Unblock the stuck open so the test process can exit.
|
||||||
|
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
t.Fatalf("a raw/ reader hung on a swapped-in FIFO after %d iterations", iters.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR #156 round 4: the pdf/docmark branch of TextIn copies the file through
|
||||||
|
// the root to a temp file; pointing the extractor back at the raw/ path by
|
||||||
|
// name stayed green because the race tests only used r.md (a stub docmark
|
||||||
|
// then received the outside file's bytes 34k times in 4s). This races a
|
||||||
|
// symlink into raw/r.docx and has a fake docmark count the sentinel in every
|
||||||
|
// document it is sent.
|
||||||
|
func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
|
||||||
|
var seen atomic.Int64
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Params struct {
|
||||||
|
Arguments struct {
|
||||||
|
ContentBase64 string `json:"content_base64"`
|
||||||
|
} `json:"arguments"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(req.Params.Arguments.ContentBase64); err == nil &&
|
||||||
|
strings.Contains(string(b), raceSentinel) {
|
||||||
|
seen.Add(1)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"converted"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL)
|
||||||
|
|
||||||
|
brain, raw, outside := raceBrain(t)
|
||||||
|
tgt := filepath.Join(raw, "r.docx")
|
||||||
|
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
startSwapper(t, func(stop *atomic.Bool) {
|
||||||
|
for i := 0; !stop.Load(); i++ {
|
||||||
|
tmp := filepath.Join(raw, ".swapdocx")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if i%2 == 0 {
|
||||||
|
_ = os.WriteFile(tmp, []byte("inside docx bytes"), 0o644)
|
||||||
|
} else {
|
||||||
|
_ = os.Symlink(outside, tmp)
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, tgt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
iters := 0
|
||||||
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
||||||
|
_, _ = api.ReadIngestFile(brain, tgt)
|
||||||
|
}
|
||||||
|
if n := seen.Load(); n > 0 {
|
||||||
|
t.Fatalf("docmark received the outside file %d times in %d iterations", n, iters)
|
||||||
|
}
|
||||||
|
t.Logf("%d iterations, 0 leaks to docmark", iters)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#153: readers of brain/raw/ followed symlinks. /pending showed the
|
||||||
|
// target's contents and promote would copy it into wiki/ (and brain-sync to
|
||||||
|
// Gitea). raw/ is writable via Syncthing, so a symlink there is reachable
|
||||||
|
// input. Every raw/ reader now treats a symlink as not-a-note.
|
||||||
|
const sentinel153 = "SENTINEL-153-OUTSIDE"
|
||||||
|
|
||||||
|
func rawWithSymlink(t *testing.T) (brain string) {
|
||||||
|
t.Helper()
|
||||||
|
brain = t.TempDir()
|
||||||
|
raw := filepath.Join(brain, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("---\ncreated_at: 2026-01-01T00:00:00Z\n---\n# s\n\n"+sentinel153), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "2026-01-01-leak.md")))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-01-02-real.md"), []byte("# real\n\nreal body"), 0o644))
|
||||||
|
return brain
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingSkipsSymlinkedNotes(t *testing.T) {
|
||||||
|
notes, err := api.ListPending(rawWithSymlink(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var names []string
|
||||||
|
for _, n := range notes {
|
||||||
|
names = append(names, n.Filename)
|
||||||
|
assert.NotContains(t, n.Excerpt, sentinel153)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"2026-01-02-real.md"}, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNoteRefusesASymlinkedSource(t *testing.T) {
|
||||||
|
brain := rawWithSymlink(t)
|
||||||
|
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-01-01-leak.md", Wing: "testing", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(brain, "wiki", "testing", "facts", "leak.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "nothing may be promoted from a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNoteRefusesASymlinkedSource(t *testing.T) {
|
||||||
|
brain := rawWithSymlink(t)
|
||||||
|
_, err := api.RejectNote(brain, api.RejectOptions{Filename: "2026-01-01-leak.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
b, _ := os.ReadFile(filepath.Join(brain, "raw", "rejected", "2026-01-01-leak.md"))
|
||||||
|
assert.False(t, strings.Contains(string(b), sentinel153))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item 4: if raw/ itself is a symlink, containment inside it means nothing.
|
||||||
|
func TestResolvePendingPromoteRejectAndReadIngestRefuseASymlinkedRawRoot(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("# x\n\n"+sentinel153), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brain, "raw")))
|
||||||
|
|
||||||
|
_, err := api.ResolveIngestPath(brain, "x.md")
|
||||||
|
assert.Error(t, err, "ingest must refuse a symlinked raw/")
|
||||||
|
notes, err := api.ListPending(brain)
|
||||||
|
assert.Error(t, err, "pending must refuse a symlinked raw/")
|
||||||
|
assert.Empty(t, notes)
|
||||||
|
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "x.md", Wing: "testing", Hall: "facts"})
|
||||||
|
assert.Error(t, err, "promote must refuse a symlinked raw/")
|
||||||
|
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "x.md"})
|
||||||
|
assert.Error(t, err, "reject must refuse a symlinked raw/")
|
||||||
|
text, err := api.ReadIngestFile(brain, filepath.Join(elsewhere, "x.md"))
|
||||||
|
assert.Error(t, err, "ReadIngestFile must refuse a symlinked raw/")
|
||||||
|
assert.NotContains(t, text, sentinel153)
|
||||||
|
// The watcher's refusal is tested in internal/watcher.
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNotANote is returned for a raw/ entry that is not a regular file — in
|
||||||
|
// particular a symlink, whose target may be anywhere (hyperguild#153).
|
||||||
|
var errNotANote = errors.New("not a regular note file in raw/ (symlinks are refused)")
|
||||||
|
|
||||||
|
// errRawNotDir means brain/raw is not a real directory (e.g. a symlink), so
|
||||||
|
// containment inside it would mean nothing (hyperguild#153 item 4).
|
||||||
|
var errRawNotDir = errors.New("brain/raw is not a real directory")
|
||||||
|
|
||||||
|
// OpenRawRoot opens brain/raw as an os.Root, refusing a raw/ that is itself a
|
||||||
|
// symlink. Reads through the root cannot leave it, even if an entry is
|
||||||
|
// swapped for a symlink between a check and the read. It returns the root
|
||||||
|
// and raw/'s path. A missing raw/ returns an error wrapping os.ErrNotExist.
|
||||||
|
func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
||||||
|
p := filepath.Join(brainDir, "raw")
|
||||||
|
fi, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
||||||
|
return nil, "", errRawNotDir
|
||||||
|
}
|
||||||
|
r, err := os.OpenRoot(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("open raw root: %w", err)
|
||||||
|
}
|
||||||
|
return r, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRawNote reads one raw/ entry by basename through the root, refusing
|
||||||
|
// anything that is not a regular file.
|
||||||
|
//
|
||||||
|
// Accepted residual: a HARD link to an outside file is a regular file and is
|
||||||
|
// read. Syncthing syncs contents, not links, so a peer cannot create one;
|
||||||
|
// fs.protected_hardlinks=1 and the pod's secrets live on a different
|
||||||
|
// filesystem from the brain volume, so a local caller cannot link them in.
|
||||||
|
// Refusing Nlink > 1 is the next step if either assumption changes.
|
||||||
|
func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
||||||
|
fi, err := r.Lstat(base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return nil, nil, errNotANote
|
||||||
|
}
|
||||||
|
f, _, err := extract.OpenRegular(r, base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
b, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return b, fi, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by
|
||||||
|
// path; it now reads through brainfs.
|
||||||
|
func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
facts := filepath.Join(brain, "wiki", "w", "facts")
|
||||||
|
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||||
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
||||||
|
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.NotContains(t, body, brainfstest.Sentinel)
|
||||||
|
|
||||||
|
p := filepath.Join(facts, "r.md")
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
|
||||||
|
fm, body, _, err := api.ReadNote(brain, "wiki/w/facts/r.md")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "y", fm["x"])
|
||||||
|
assert.Contains(t, body, "inside")
|
||||||
|
read := func() string {
|
||||||
|
fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md")
|
||||||
|
s := body
|
||||||
|
for k, v := range fm {
|
||||||
|
s += k + v
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
|
||||||
|
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||||
@@ -45,6 +47,11 @@ type UpdateNoteOptions struct {
|
|||||||
Slug string
|
Slug string
|
||||||
Content string // new full body (whole-note replace)
|
Content string // new full body (whole-note replace)
|
||||||
Reason string // optional; stamped as supersede_reason
|
Reason string // optional; stamped as supersede_reason
|
||||||
|
// Origin/WrittenBy re-stamp provenance to the updating principal: the
|
||||||
|
// body is replaced wholesale, so the last writer is the author of what
|
||||||
|
// a reader sees (hyperguild#145). Never taken from Content's fragment.
|
||||||
|
Origin string
|
||||||
|
WrittenBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNote supersedes an existing note in place. It replaces the body
|
// UpdateNote supersedes an existing note in place. It replaces the body
|
||||||
@@ -64,6 +71,14 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
return "", "", "", fmt.Errorf("content is required")
|
return "", "", "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same reason as WriteNote: the strip belongs in the primitive so every
|
||||||
|
// door inherits it.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
opts.Content = clean
|
||||||
|
|
||||||
var rel string
|
var rel string
|
||||||
if opts.Path != "" {
|
if opts.Path != "" {
|
||||||
rel = opts.Path
|
rel = opts.Path
|
||||||
@@ -90,14 +105,65 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
}
|
}
|
||||||
priorHash = ContentHash(prior)
|
priorHash = ContentHash(prior)
|
||||||
|
|
||||||
|
// A caller may lead opts.Content with its own frontmatter fence to set or
|
||||||
|
// change fields on supersede (e.g. the claim envelope). Split it off
|
||||||
|
// before it reaches the body — left un-split, it would render as a
|
||||||
|
// second, inert `---`-fenced block of literal text rather than taking
|
||||||
|
// effect, and any claim-envelope fields inside it would reach disk
|
||||||
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
||||||
|
// writeHallNote must guard a change to the same fields).
|
||||||
|
contentFM, contentBody := parseFrontmatter(opts.Content)
|
||||||
|
|
||||||
fm, _ := parseFrontmatter(string(prior))
|
fm, _ := parseFrontmatter(string(prior))
|
||||||
|
for _, l := range contentFM.lines {
|
||||||
|
// updated_at/supersedes/supersede_reason are this function's own
|
||||||
|
// output, not something a caller's fragment sets — updated_at and
|
||||||
|
// supersedes are already unconditionally re-set below regardless of
|
||||||
|
// what the loop does with them, but supersede_reason is only
|
||||||
|
// re-set when opts.Reason is non-empty, so without this skip a
|
||||||
|
// caller could set it via its own fragment and have it survive
|
||||||
|
// even when Reason is empty, bypassing the one intended entry
|
||||||
|
// point for that field (hyperguild#125, found in #111's review).
|
||||||
|
// Skipping all three here rather than relying on the unconditional
|
||||||
|
// overwrites below to save the other two keeps the invariant
|
||||||
|
// explicit at the one place it can be silently broken by a future
|
||||||
|
// reordering, not implicit in call order.
|
||||||
|
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" ||
|
||||||
|
l.key == "origin" || l.key == "written_by" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fm.set(l.key, l.value)
|
||||||
|
}
|
||||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
fm.set("supersedes", priorHash)
|
fm.set("supersedes", priorHash)
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("supersede_reason", opts.Reason)
|
fm.set("supersede_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := []byte(fm.render() + opts.Content)
|
// Validate the envelope AFTER the merge, against the full field set that
|
||||||
|
// is about to reach disk — not just the caller's fragment. Validating the
|
||||||
|
// fragment alone would miss a caller that supersedes claim_provenance to
|
||||||
|
// empty while half_life/applies survive untouched from the prior note:
|
||||||
|
// each side looks individually fine, but the merged result is an
|
||||||
|
// unprovenanced envelope, which is the exact laundering this check
|
||||||
|
// exists to catch. (Caught in review before merge, not filed after —
|
||||||
|
// worth being honest about in the history: this second pass was the
|
||||||
|
// actual fix; the first one validated the wrong document.)
|
||||||
|
envelope := make(map[string]string, len(fm.lines))
|
||||||
|
for _, l := range fm.lines {
|
||||||
|
if l.key != "" {
|
||||||
|
envelope[l.key] = l.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := claim.ValidateEnvelope(envelope); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
out := []byte(stamped)
|
||||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
@@ -109,11 +175,17 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
// write primitive behind brain_get: the hash it returns equals the hash
|
// write primitive behind brain_get: the hash it returns equals the hash
|
||||||
// brain_write / brain_update returned for the same bytes.
|
// brain_write / brain_update returned for the same bytes.
|
||||||
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
|
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
|
||||||
_, abs, err := resolveWithin(brainDir, relPath)
|
rel, _, err := resolveWithin(brainDir, relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(abs)
|
// Through the brain root, regular files only (hyperguild#157).
|
||||||
|
bfs, err := brainfs.Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("read note: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
|
raw, err := bfs.ReadRegular(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
|
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
|
||||||
|
|||||||
@@ -52,6 +52,114 @@ func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
|||||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// hyperguild#111: WriteNote validates the claim envelope before create;
|
||||||
|
// UpdateNote didn't before supersede, so the same rule must hold on
|
||||||
|
// change as on birth, fail-closed, nothing written on rejection.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: forever\n---\n# New\n\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\napplies: universal\n---\nnew\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\nold\n")
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "half_life: durable")
|
||||||
|
assert.Contains(t, s, "applies: universal")
|
||||||
|
assert.Contains(t, s, "claim_provenance: asserted")
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx", "prior fields survive")
|
||||||
|
assert.Contains(t, s, "new body")
|
||||||
|
assert.Equal(t, 2, strings.Count(s, "---\n"),
|
||||||
|
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
|
||||||
|
// Found in review, not written speculatively: validating only the
|
||||||
|
// caller's fragment (rather than the merged result) let a caller blank
|
||||||
|
// out claim_provenance alone while half_life/applies survived from the
|
||||||
|
// prior note untouched — each side looks fine on its own, the merged
|
||||||
|
// envelope on disk is the unprovenanced one the whole check exists to
|
||||||
|
// catch.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nclaim_provenance: \n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
||||||
|
// Regression pin: plain fence-less content (the common case) must behave
|
||||||
|
// exactly as it did before the envelope-merge fix.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
||||||
|
|
||||||
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "# New\n\nnew body\n",
|
||||||
|
Reason: "facts changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rel, relPath)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
assert.NotEqual(t, hash, priorHash)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "# New")
|
||||||
|
assert.Contains(t, s, "custom: keep-me")
|
||||||
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
@@ -123,6 +231,45 @@ func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
|||||||
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteSupersedeReasonNotSettableViaCallerFragmentWhenReasonOptionEmpty(t *testing.T) {
|
||||||
|
// hyperguild#125, found in #111's review: the merge loop applied every
|
||||||
|
// field from the caller's leading frontmatter fragment, including
|
||||||
|
// supersede_reason -- which is only re-set unconditionally when
|
||||||
|
// opts.Reason is non-empty, so a caller could set it via its own
|
||||||
|
// fragment and have it survive even with Reason left empty, bypassing
|
||||||
|
// the one intended entry point for that field.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nsupersede_reason: snuck in via the fragment\n---\nnew\n",
|
||||||
|
// Reason deliberately left empty.
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(got), "supersede_reason",
|
||||||
|
"supersede_reason must not appear at all when opts.Reason is empty, regardless of what the fragment tried to set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteSupersedeReasonFromOptionStillWorks(t *testing.T) {
|
||||||
|
// Regression pin: the legitimate entry point (opts.Reason) must be
|
||||||
|
// completely unaffected by #125's fix.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel, Content: "new\n", Reason: "facts changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "supersede_reason: facts changed")
|
||||||
|
}
|
||||||
|
|
||||||
func TestContentHashStable(t *testing.T) {
|
func TestContentHashStable(t *testing.T) {
|
||||||
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
||||||
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ type FileBuffer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bufferLine struct {
|
type bufferLine struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Entry capture.AuditEntry `json:"entry"`
|
Entry capture.AuditEntry `json:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
|||||||
// unwritableBuffer always reports it cannot be written (floor condition).
|
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||||
type unwritableBuffer struct{}
|
type unwritableBuffer struct{}
|
||||||
|
|
||||||
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
||||||
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
||||||
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
||||||
func (unwritableBuffer) Confirm(string) error { return nil }
|
func (unwritableBuffer) Confirm(string) error { return nil }
|
||||||
|
|
||||||
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -49,7 +49,19 @@ func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
|||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode)
|
return &RejectedError{Status: resp.StatusCode}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RejectedError is a non-2xx ntfy response.
|
||||||
|
type RejectedError struct{ Status int }
|
||||||
|
|
||||||
|
func (e *RejectedError) Error() string { return fmt.Sprintf("ntfy notify: status %d", e.Status) }
|
||||||
|
|
||||||
|
// Permanent reports whether resending the same message cannot succeed: a 4xx
|
||||||
|
// other than 429 (rate limited). Callers may drop such a message; anything
|
||||||
|
// else (5xx, 429, transport errors) is an outage and must be retried.
|
||||||
|
func (e *RejectedError) Permanent() bool {
|
||||||
|
return e.Status >= 400 && e.Status < 500 && e.Status != http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A non-2xx ntfy response is a *RejectedError, and only a client-side refusal
|
||||||
|
// other than rate limiting is permanent: callers may drop a message on a
|
||||||
|
// permanent rejection and must retry the rest (hyperguild#157, PR #160).
|
||||||
|
func TestNotifyClassifiesRejectionsAsPermanentOrNot(t *testing.T) {
|
||||||
|
for status, permanent := range map[int]bool{413: true, 400: true, 403: true, 429: false, 500: false, 503: false} {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) }))
|
||||||
|
err := NewNtfyNotifier(srv.URL, "").Notify(context.Background(), "m")
|
||||||
|
srv.Close()
|
||||||
|
var rej *RejectedError
|
||||||
|
require.True(t, errors.As(err, &rej), "status %d: %v", status, err)
|
||||||
|
assert.Equal(t, status, rej.Status)
|
||||||
|
assert.Equal(t, permanent, rej.Permanent(), "status %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||||
|
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
|
||||||
|
|
||||||
|
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
|
||||||
|
// syntax example rather than a link. Same exclusion brain-gardener's audit
|
||||||
|
// applies on the read side, so the write gate and the audit agree on what
|
||||||
|
// counts as a link at all.
|
||||||
|
var (
|
||||||
|
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
|
||||||
|
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
|
||||||
|
// text (or its target) as plain prose. It returns the rewritten content and
|
||||||
|
// the sorted, de-duplicated targets it unwrapped.
|
||||||
|
//
|
||||||
|
// WHY UNWRAP RATHER THAN REJECT THE WRITE
|
||||||
|
// Agents write [[...]] around anything that feels related — a skill name, a
|
||||||
|
// repo, an agent-local memory file — and none of those are brain entries.
|
||||||
|
// Every one of those becomes a permanent [high] broken-link finding that no
|
||||||
|
// deterministic Fixer can repair, because there is no target to repoint to:
|
||||||
|
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
|
||||||
|
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
|
||||||
|
// instead would push the failure onto claudewatcher's unattended ingest path,
|
||||||
|
// where a refused write is a lost learning. Unwrapping keeps the author's
|
||||||
|
// words, keeps the write, and keeps the graph honest.
|
||||||
|
//
|
||||||
|
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
|
||||||
|
// returns content unchanged: nothing resolving is a bootstrap state, not
|
||||||
|
// evidence that every target was invented.
|
||||||
|
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||||
|
index, err := entryIndex(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(index) == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := codeSpans(content)
|
||||||
|
stripped := map[string]struct{}{}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
last := 0
|
||||||
|
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||||
|
start, end := m[0], m[1]
|
||||||
|
if overlaps(start, end, code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(content[m[2]:m[3]])
|
||||||
|
if _, ok := index[linkKey(target)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
display := target
|
||||||
|
if m[4] >= 0 {
|
||||||
|
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
|
||||||
|
display = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:start])
|
||||||
|
b.WriteString(display)
|
||||||
|
last = end
|
||||||
|
stripped[target] = struct{}{}
|
||||||
|
}
|
||||||
|
if last == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:])
|
||||||
|
|
||||||
|
targets := make([]string, 0, len(stripped))
|
||||||
|
for t := range stripped {
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
sort.Strings(targets)
|
||||||
|
return b.String(), targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkedTrees are the two trees that hold linkable entries — the same pair
|
||||||
|
// package notes admits under ScopeNotes. Duplicated rather than imported
|
||||||
|
// because that list is unexported there and this is a read-only mirror.
|
||||||
|
var linkedTrees = []string{"knowledge", "wiki"}
|
||||||
|
|
||||||
|
// entryIndex maps every form a wikilink may name an entry by — its bare slug
|
||||||
|
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
|
||||||
|
// present marker. Mirrors brain-gardener's resolver so an accepted link is
|
||||||
|
// one the audit will also resolve.
|
||||||
|
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||||
|
index := map[string]struct{}{}
|
||||||
|
for _, dir := range linkedTrees {
|
||||||
|
root := filepath.Join(brainDir, dir)
|
||||||
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
||||||
|
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkKey normalises a link target for index lookup: an exact path form is
|
||||||
|
// tried as written, and anything else falls back to its final segment.
|
||||||
|
func linkKey(target string) string {
|
||||||
|
return strings.TrimSuffix(target, ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
|
||||||
|
func codeSpans(content string) [][2]int {
|
||||||
|
var spans [][2]int
|
||||||
|
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlaps reports whether [start,end) intersects any span.
|
||||||
|
func overlaps(start, end int, spans [][2]int) bool {
|
||||||
|
for _, s := range spans {
|
||||||
|
if start < s[1] && end > s[0] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCorpus writes the two entries every linkcheck case resolves against.
|
||||||
|
func seedCorpus(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
|
||||||
|
seedNote(t, dir, "knowledge/legacy-entry.md",
|
||||||
|
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
stripped []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bare slug that resolves is kept",
|
||||||
|
body: "See [[real-entry]] for the rule.",
|
||||||
|
want: "See [[real-entry]] for the rule.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path-style target that resolves is kept",
|
||||||
|
body: "See [[homelab/facts/real-entry]].",
|
||||||
|
want: "See [[homelab/facts/real-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "knowledge/ entry resolves by bare slug",
|
||||||
|
body: "See [[legacy-entry]].",
|
||||||
|
want: "See [[legacy-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable target is unwrapped to plain text",
|
||||||
|
body: "Related: [[secret-hygiene]].",
|
||||||
|
want: "Related: secret-hygiene.",
|
||||||
|
stripped: []string{"secret-hygiene"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable aliased target keeps its display text",
|
||||||
|
body: "Related: [[feedback_check_host_not_assume|check the host]].",
|
||||||
|
want: "Related: check the host.",
|
||||||
|
stripped: []string{"feedback_check_host_not_assume"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside a fenced code block is left alone",
|
||||||
|
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside an inline code span is left alone",
|
||||||
|
body: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
want: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every unresolvable target is reported once, sorted",
|
||||||
|
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
|
||||||
|
want: "zeta-gap and alpha-gap and zeta-gap again.",
|
||||||
|
stripped: []string{"alpha-gap", "zeta-gap"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedCorpus(t, dir)
|
||||||
|
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
assert.Equal(t, tc.stripped, stripped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
|
||||||
|
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
|
||||||
|
// into an unresolved one — an empty corpus is a bootstrap state, not
|
||||||
|
// evidence that the author invented the target.
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "See [[real-entry]].", got)
|
||||||
|
assert.Nil(t, stripped)
|
||||||
|
}
|
||||||
@@ -54,11 +54,24 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||||
needle := strings.ToLower(strings.TrimSpace(title))
|
title = strings.TrimSpace(title)
|
||||||
if needle == "" {
|
if title == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
idx := strings.Index(lowerContent, needle)
|
|
||||||
|
// A one-word title has to match the prose case-exactly. Matched
|
||||||
|
// case-insensitively, it links every entry that happens to use the
|
||||||
|
// word: "WRONG" (wiki/telos/decisions/wrong.md) collected 185 inbound
|
||||||
|
// See-also links and "Status" 136, making the two most-linked nodes in
|
||||||
|
// the graph artifacts of the English language rather than references.
|
||||||
|
// Multi-word titles keep the case-insensitive match — "risk tier
|
||||||
|
// middleware" in prose is a real mention however it is capitalised,
|
||||||
|
// and a three-word collision is not an accident.
|
||||||
|
haystack, needle := lowerContent, strings.ToLower(title)
|
||||||
|
if isSingleWord(title) {
|
||||||
|
haystack, needle = content, title
|
||||||
|
}
|
||||||
|
idx := strings.Index(haystack, needle)
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
out = append(out, TunnelCandidate{
|
out = append(out, TunnelCandidate{
|
||||||
TargetPath: filepath.ToSlash(rel),
|
TargetPath: filepath.ToSlash(rel),
|
||||||
MatchedTerm: title,
|
MatchedTerm: title,
|
||||||
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -79,6 +92,11 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSingleWord reports whether title is one whitespace-free token.
|
||||||
|
func isSingleWord(title string) bool {
|
||||||
|
return !strings.ContainsAny(title, " \t")
|
||||||
|
}
|
||||||
|
|
||||||
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
||||||
// bounded by non-alphanumeric characters (or string edges).
|
// bounded by non-alphanumeric characters (or string edges).
|
||||||
func isWholeWord(s string, idx, n int) bool {
|
func isWholeWord(s string, idx, n int) bool {
|
||||||
|
|||||||
@@ -102,16 +102,29 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
||||||
"---\ntitle: Routing\n---\nbody.\n")
|
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
|
|
||||||
// Substring of title appears in content, but not as a whole word.
|
// Substring of title appears in content, but not as a whole word.
|
||||||
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
candidates, err := brain.DetectTunnels(dir, "the routing tables were rebuilt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, candidates, 1)
|
require.Len(t, candidates, 1)
|
||||||
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title whose only appearance is lowercase inside another word is
|
||||||
|
// not even a candidate: it would fill the review file with English rather than
|
||||||
|
// with references.
|
||||||
|
func TestDetectTunnels_SingleWordSubstringIsNotACandidate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||||
|
"---\ntitle: Routing\n---\nbody.\n")
|
||||||
|
|
||||||
|
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
||||||
@@ -126,20 +139,21 @@ func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
|||||||
|
|
||||||
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
// Existing note in a different wing whose title is "Routing".
|
// Existing note in a different wing whose title is "Routing Table".
|
||||||
seedNote(t, dir, "wiki/other/facts/routing.md",
|
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
||||||
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
// Source note in another wing whose body mentions "routing tables"
|
||||||
|
// (substring match only — the title is not there as a whole word).
|
||||||
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
||||||
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
"---\nwing: jepa-fx\nhall: facts\n---\nrouting tables rebuilt\n")
|
||||||
|
|
||||||
require.NoError(t, brain.AutoTunnel(dir,
|
require.NoError(t, brain.AutoTunnel(dir,
|
||||||
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
||||||
|
|
||||||
// Source must not get auto-linked (fuzzy).
|
// Source must not get auto-linked (fuzzy).
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
assert.NotContains(t, string(got), "[[other/facts/routing-table]]")
|
||||||
|
|
||||||
// Candidates file must list the pair.
|
// Candidates file must list the pair.
|
||||||
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
||||||
@@ -148,7 +162,7 @@ func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
|||||||
body, err := os.ReadFile(matches[0])
|
body, err := os.ReadFile(matches[0])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
||||||
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
assert.Contains(t, string(body), "wiki/other/facts/routing-table.md")
|
||||||
assert.Contains(t, string(body), "Routing")
|
assert.Contains(t, string(body), "Routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +189,35 @@ func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
|||||||
assert.Contains(t, s, "[[a/facts/old]]")
|
assert.Contains(t, s, "[[a/facts/old]]")
|
||||||
assert.Contains(t, s, "[[b/facts/y]]")
|
assert.Contains(t, s, "[[b/facts/y]]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title matched case-insensitively links every entry that happens
|
||||||
|
// to use that word. Live cost before this rule: 185 entries auto-linked to
|
||||||
|
// wiki/telos/decisions/wrong.md (title "WRONG") and 136 to .../status.md,
|
||||||
|
// making the two most-linked nodes in the graph pure artifacts.
|
||||||
|
func TestDetectTunnels_SingleWordTitleNeedsAnExactCaseMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/telos/decisions/wrong.md",
|
||||||
|
"---\nwing: telos\nhall: decisions\ntitle: WRONG\n---\n# WRONG\n\nRetired beliefs.\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "The obvious fix was to reject the write. Wrong here.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, c := range got {
|
||||||
|
assert.False(t, c.Exact, "a lowercase prose word must not auto-link a one-word title: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = brain.DetectTunnels(dir, "The beliefs listed under WRONG were retired.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact, "the title's own casing appearing in prose is a real reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_MultiWordTitleStaysCaseInsensitive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/risk-tier.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Risk Tier Middleware'\n---\nbody\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "We rebuilt the risk tier middleware last week.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Package brainfs reads the brain directory through an os.Root (hyperguild#157).
|
||||||
|
// The brain dir is a Syncthing sendreceive folder and Syncthing syncs
|
||||||
|
// symlinks; a reader that walks and reads by path follows a planted symlink
|
||||||
|
// out of the brain, and blocks forever on a planted FIFO. Readers that go
|
||||||
|
// through a Brain only ever read regular files inside the brain: os.Root
|
||||||
|
// refuses to leave the root even if an entry is swapped mid-read, and every
|
||||||
|
// open is non-blocking with the type checked on the opened handle.
|
||||||
|
package brainfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotRegular is returned for an entry that is not a regular file.
|
||||||
|
var ErrNotRegular = errors.New("not a regular file")
|
||||||
|
|
||||||
|
// ErrTooLarge is returned by ReadRegularMax for a file over the limit.
|
||||||
|
var ErrTooLarge = errors.New("file too large")
|
||||||
|
|
||||||
|
// Brain is an open brain directory.
|
||||||
|
type Brain struct {
|
||||||
|
root *os.Root
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens brainDir (which may itself be a mount symlink) as a root.
|
||||||
|
func Open(brainDir string) (*Brain, error) {
|
||||||
|
r, err := os.OpenRoot(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open brain root: %w", err)
|
||||||
|
}
|
||||||
|
return &Brain{root: r, dir: brainDir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the root.
|
||||||
|
func (b *Brain) Close() error { return b.root.Close() }
|
||||||
|
|
||||||
|
// Rel converts a path under the brain dir to a root-relative path.
|
||||||
|
func (b *Brain) Rel(path string) (string, error) {
|
||||||
|
rel, err := filepath.Rel(b.dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if rel == ".." || len(rel) > 2 && rel[:3] == ".."+string(filepath.Separator) {
|
||||||
|
return "", fmt.Errorf("%s is outside the brain dir", path)
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat stats a brain-relative path through the root without following a
|
||||||
|
// final symlink.
|
||||||
|
func (b *Brain) Lstat(rel string) (fs.FileInfo, error) {
|
||||||
|
return b.root.Lstat(filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// realDir returns ErrNotRegular unless every component of rel is a real
|
||||||
|
// directory. A swap after the check is not caught (accepted residual,
|
||||||
|
// hyperguild#157).
|
||||||
|
func (b *Brain) realDir(rel string) error {
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
for _, part := range strings.Split(rel, "/") {
|
||||||
|
prefix = path.Join(prefix, part)
|
||||||
|
fi, err := b.root.Lstat(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
return fmt.Errorf("%s: walk root component %s is not a directory: %w", rel, prefix, ErrNotRegular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDirRegular lists the regular files directly in a brain-relative
|
||||||
|
// directory, by name. Every component of relDir must be a real directory
|
||||||
|
// (ErrNotRegular otherwise); symlinks, FIFOs, devices and subdirectories in
|
||||||
|
// it are left out.
|
||||||
|
func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
||||||
|
relDir = filepath.ToSlash(relDir)
|
||||||
|
if err := b.realDir(relDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ents, err := fs.ReadDir(b.root.FS(), relDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range ents {
|
||||||
|
if e.Type().IsRegular() {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRegular reads a brain-relative path, refusing anything that is not a
|
||||||
|
// regular file, both before the open and on the opened handle.
|
||||||
|
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
|
||||||
|
fi, err := b.root.Lstat(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
||||||
|
}
|
||||||
|
return b.readOpenedMax(rel, -1, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOpened opens rel non-blocking and reads it only if the OPENED handle is
|
||||||
|
// a regular file. This is the check that matters under a race: an entry
|
||||||
|
// swapped for a FIFO after the Lstat, with a writer holding it open, would
|
||||||
|
// otherwise park io.ReadAll forever (PR #161 review: removing it hung search
|
||||||
|
// after 16 iterations).
|
||||||
|
//
|
||||||
|
// Accepted residual, as in api/rawroot.go: a HARD link to an outside file is
|
||||||
|
// a regular file and is read. Syncthing syncs contents, not links, so a peer
|
||||||
|
// cannot create one; fs.protected_hardlinks=1 and the pod's secrets are on a
|
||||||
|
// different filesystem from the brain volume, so a local caller cannot link
|
||||||
|
// them in. Refusing Nlink > 1 is the next step if either changes.
|
||||||
|
func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(rel, -1, nil) }
|
||||||
|
|
||||||
|
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
|
||||||
|
// handle before reading (max < 0 means no limit).
|
||||||
|
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
|
||||||
|
fi, err := b.root.Lstat(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
||||||
|
}
|
||||||
|
return b.readOpenedMax(rel, max, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOpenedMax opens rel and reads it if the opened handle is a regular file
|
||||||
|
// and, when lfi is set, the same file the caller Lstat-ed: os.Root follows an
|
||||||
|
// in-root final symlink even with O_NOFOLLOW, so a symlink to another brain
|
||||||
|
// file swapped in between the Lstat and the open is refused only here.
|
||||||
|
func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]byte, error) {
|
||||||
|
f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
hfi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hfi.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
||||||
|
}
|
||||||
|
if lfi != nil && !os.SameFile(lfi, hfi) {
|
||||||
|
return nil, fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
|
||||||
|
}
|
||||||
|
if max >= 0 && hfi.Size() > max {
|
||||||
|
return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge)
|
||||||
|
}
|
||||||
|
var r io.Reader = f
|
||||||
|
if wrapAfterStat != nil {
|
||||||
|
r = wrapAfterStat(r)
|
||||||
|
}
|
||||||
|
if max < 0 {
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
// The handle's size can grow after the check; bound the read itself.
|
||||||
|
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > max {
|
||||||
|
return nil, fmt.Errorf("%s grew past %d bytes while read: %w", rel, max, ErrTooLarge)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapAfterStat is a test seam: it runs between the size check and the read,
|
||||||
|
// and wraps the reader the read consumes. A package global: brainfs tests
|
||||||
|
// must not use t.Parallel.
|
||||||
|
var wrapAfterStat func(io.Reader) io.Reader
|
||||||
|
|
||||||
|
// WalkRegular walks a brain-relative directory through the root and calls fn
|
||||||
|
// for each regular file. Directories are descended; symlinks, FIFOs and
|
||||||
|
// devices found by the walk are skipped. A walk root with any component that
|
||||||
|
// is not a real directory goes to onErr as ErrNotRegular and nothing is
|
||||||
|
// yielded. An entry error is passed to onErr and the walk continues; a nil
|
||||||
|
// onErr ignores it.
|
||||||
|
//
|
||||||
|
// Accepted residual (hyperguild#157): a directory swapped for a symlink
|
||||||
|
// between the listing and the read is followed. os.Root still keeps the read
|
||||||
|
// inside the brain, but not inside relDir.
|
||||||
|
func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func(rel string, err error)) error {
|
||||||
|
relDir = filepath.ToSlash(relDir)
|
||||||
|
// fs.WalkDir stats its root with a follow, and os.Root allows a symlink
|
||||||
|
// that stays inside the brain: knowledge -> . would alias the whole
|
||||||
|
// brain. Require every component of the root to be a real directory
|
||||||
|
// (Lstat checks only the last one).
|
||||||
|
err := b.realDir(relDir)
|
||||||
|
if err != nil {
|
||||||
|
if onErr != nil {
|
||||||
|
onErr(relDir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fs.WalkDir(b.root.FS(), relDir, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
if onErr != nil {
|
||||||
|
onErr(p, err)
|
||||||
|
}
|
||||||
|
if d != nil && d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() || !d.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fn(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package brainfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The handle check is what stops a FIFO swapped in after the Lstat. A FIFO
|
||||||
|
// with no writer reads EOF at once, so a race test could not see the check
|
||||||
|
// missing; here a writer holds the FIFO open, as any local process can, and
|
||||||
|
// readOpened must refuse it promptly (PR #161 review).
|
||||||
|
func TestReadOpenedRefusesAFIFOHeldOpenByAWriter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fifo := filepath.Join(dir, "p.md")
|
||||||
|
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
|
||||||
|
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = w.Close() }()
|
||||||
|
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := b.readOpened("p.md"); done <- err }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.True(t, errors.Is(err, ErrNotRegular), "got %v", err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("readOpened blocked on a FIFO with a writer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRegularRefusesSymlinksAndReadsRegularFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "ok.md"), []byte("ok"), 0o644))
|
||||||
|
outside := filepath.Join(t.TempDir(), "s.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link.md")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
got, err := b.ReadRegular("ok.md")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok", string(got))
|
||||||
|
_, err = b.ReadRegular("link.md")
|
||||||
|
assert.True(t, errors.Is(err, ErrNotRegular), "got %v", err)
|
||||||
|
_, err = b.ReadRegular("../" + filepath.Base(outside))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelAcceptsPathsInsideAndRefusesEscapes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
filepath.Join(dir, "wiki", "x.md"): "wiki/x.md",
|
||||||
|
filepath.Join(dir, "..foo", "x"): "..foo/x",
|
||||||
|
dir: ".",
|
||||||
|
} {
|
||||||
|
got, err := b.Rel(in)
|
||||||
|
require.NoError(t, err, in)
|
||||||
|
assert.Equal(t, want, got, in)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{filepath.Join(dir, "..", "x"), dir + "-evil/x", "/etc/passwd", filepath.Join(dir, "wiki", "..", "..", "x")} {
|
||||||
|
_, err := b.Rel(bad)
|
||||||
|
assert.Error(t, err, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalkRegularYieldsOnlyRegularFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "n.md"), []byte("n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(dir, "a", "dirlink")))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join(dir, "a", "n.md"), filepath.Join(dir, "a", "filelink.md")))
|
||||||
|
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "a", "p.md"), 0o644))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
var got []string
|
||||||
|
require.NoError(t, b.WalkRegular(".", func(rel string) error { got = append(got, rel); return nil }, nil))
|
||||||
|
assert.Equal(t, []string{"a/n.md"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRegularMaxRefusesAFileOverTheLimit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), make([]byte, 100), 0o644))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
_, err = b.ReadRegularMax("big.md", 99)
|
||||||
|
assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err)
|
||||||
|
got, err := b.ReadRegularMax("big.md", 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, got, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRegularMaxRefusesAndStopsReadingAFileThatGrowsPastTheLimit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "grow.md")
|
||||||
|
require.NoError(t, os.WriteFile(p, make([]byte, 10), 0o644))
|
||||||
|
consumed := &countingReader{}
|
||||||
|
wrapAfterStat = func(r io.Reader) io.Reader {
|
||||||
|
f, err := os.OpenFile(p, os.O_WRONLY|os.O_APPEND, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = f.Write(make([]byte, 1000))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
consumed.r = r
|
||||||
|
return consumed
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { wrapAfterStat = nil })
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
got, err := b.ReadRegularMax("grow.md", 100)
|
||||||
|
assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
assert.LessOrEqual(t, consumed.n, 101, "read past max+1 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReader struct {
|
||||||
|
r io.Reader
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.r.Read(p)
|
||||||
|
c.n += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLstatDoesNotFollowSymlinksAndRefusesEscapes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outside := filepath.Join(t.TempDir(), "o.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("o"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "l.md")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
fi, err := b.Lstat("l.md")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fs.ModeSymlink, fi.Mode().Type())
|
||||||
|
_, err = b.Lstat("../" + filepath.Base(filepath.Dir(outside)) + "/o.md")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalkRegularReportsASymlinkedWalkRootAndYieldsNothing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "real"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "n.md"), []byte("n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink("real", filepath.Join(dir, "alias")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
var got []string
|
||||||
|
var errs []error
|
||||||
|
require.NoError(t, b.WalkRegular("alias", func(rel string) error { got = append(got, rel); return nil },
|
||||||
|
func(_ string, err error) { errs = append(errs, err) }))
|
||||||
|
assert.Empty(t, got)
|
||||||
|
require.Len(t, errs, 1)
|
||||||
|
assert.ErrorIs(t, errs[0], ErrNotRegular)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalkRegularReportsASymlinkedIntermediateComponentOfTheWalkRoot(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "real", "sub"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "sub", "n.md"), []byte("n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink("real", filepath.Join(dir, "alias")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
var got []string
|
||||||
|
var errs []error
|
||||||
|
require.NoError(t, b.WalkRegular("alias/sub", func(rel string) error { got = append(got, rel); return nil },
|
||||||
|
func(_ string, err error) { errs = append(errs, err) }))
|
||||||
|
assert.Empty(t, got)
|
||||||
|
require.Len(t, errs, 1)
|
||||||
|
assert.ErrorIs(t, errs[0], ErrNotRegular)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadDirRegularListsOnlyRegularFilesAndRefusesSymlinkedComponents(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b", "sub"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "b", "n.md"), []byte("n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "other.md"), []byte("o"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "other.md"), filepath.Join(dir, "a", "b", "l.md")))
|
||||||
|
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "a", "b", "f.md"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink("a", filepath.Join(dir, "alias")))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "x"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "a"), filepath.Join(dir, "x", "mid")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
|
||||||
|
names, err := b.ReadDirRegular("a/b")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"n.md"}, names)
|
||||||
|
for _, rel := range []string{"alias/b", "x/mid/b"} {
|
||||||
|
_, err := b.ReadDirRegular(rel)
|
||||||
|
assert.ErrorIs(t, err, ErrNotRegular, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A final component swapped for a symlink to another regular file in the
|
||||||
|
// brain between the Lstat and the open: os.Root follows in-root links, so
|
||||||
|
// only comparing the two (os.SameFile) refuses it.
|
||||||
|
func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
|
||||||
|
p := filepath.Join(dir, "r.md")
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
|
||||||
|
var stop atomic.Bool
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
tmp := p + ".swap"
|
||||||
|
for i := 0; !stop.Load(); i++ {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if i%2 == 0 {
|
||||||
|
_ = os.WriteFile(tmp, []byte("inside"), 0o644)
|
||||||
|
} else {
|
||||||
|
_ = os.Symlink("s.md", tmp)
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
leaks, ok := 0, 0
|
||||||
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
||||||
|
got, err := b.ReadRegular("r.md")
|
||||||
|
if err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
if string(got) == "zqsamefilesentinel" {
|
||||||
|
leaks++
|
||||||
|
}
|
||||||
|
got, _ = b.ReadRegularMax("r.md", 1<<20)
|
||||||
|
if string(got) == "zqsamefilesentinel" {
|
||||||
|
leaks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stop.Store(true)
|
||||||
|
wg.Wait()
|
||||||
|
assert.Zero(t, leaks)
|
||||||
|
assert.Positive(t, ok, "positive control: the regular file is read")
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package brainfstest is the shared harness for hyperguild#157's swap-race
|
||||||
|
// tests: it keeps swapping a regular note, a symlink to an outside file, or a
|
||||||
|
// FIFO (with a writer held open) into a path while a reader under test loops.
|
||||||
|
// It observes two things: no sentinel byte from the outside file reaches the
|
||||||
|
// reader's output, and the reader returns within a timeout. It does not cover
|
||||||
|
// hard links, other paths into the reader, or races it does not generate.
|
||||||
|
package brainfstest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel marks the outside file's contents.
|
||||||
|
const Sentinel = "zqbrainfssentinelonefiftyseven"
|
||||||
|
|
||||||
|
// outsideContent carries Sentinel in every field a reader might extract: the
|
||||||
|
// frontmatter title and aliases, a heading, a wikilink and the body.
|
||||||
|
const outsideContent = "---\ntitle: " + Sentinel + "\naliases:\n - " + Sentinel + "\n---\n# " + Sentinel +
|
||||||
|
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
|
||||||
|
|
||||||
|
// Outside writes a file outside any brain dir containing Sentinel.
|
||||||
|
func Outside(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
var swapSeq atomic.Int64
|
||||||
|
|
||||||
|
// StartSwap renames, in a loop, a regular note (content inside) and either
|
||||||
|
// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a
|
||||||
|
// writer held open until the swapper exits, so a reader that skips the
|
||||||
|
// handle check parks forever instead of reading EOF. The swapper is stopped
|
||||||
|
// and awaited before t.TempDir cleanup.
|
||||||
|
func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
|
||||||
|
t.Helper()
|
||||||
|
var stop atomic.Bool
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// A name of its own: swappers on one path share a test, and one
|
||||||
|
// opening another's FIFO for write would block forever.
|
||||||
|
tmp := fmt.Sprintf("%s.swap%d", path, swapSeq.Add(1))
|
||||||
|
// A relative link: os.Root refuses every absolute link on its own, so
|
||||||
|
// an absolute in-brain target would test nothing.
|
||||||
|
link := outside
|
||||||
|
if rel, err := filepath.Rel(filepath.Dir(path), outside); err == nil {
|
||||||
|
link = rel
|
||||||
|
}
|
||||||
|
var writers []*os.File
|
||||||
|
defer func() {
|
||||||
|
for _, w := range writers {
|
||||||
|
_ = w.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for i := 0; !stop.Load(); i++ {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
switch {
|
||||||
|
case i%2 == 0:
|
||||||
|
_ = os.WriteFile(tmp, []byte(inside), 0o644)
|
||||||
|
case fifo:
|
||||||
|
if err := syscall.Mkfifo(tmp, 0o644); err == nil {
|
||||||
|
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
||||||
|
writers = append(writers, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_ = os.Symlink(link, tmp)
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSentinelAt writes a file carrying Sentinel at p (creating its
|
||||||
|
// directory), for an in-brain symlink target.
|
||||||
|
func WriteSentinelAt(t *testing.T, p string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertNoLeak loops read for d while a symlink swapper runs on path, and
|
||||||
|
// fails if any output contains Sentinel. The symlink points outside the
|
||||||
|
// brain; os.Root refuses that on its own, so pair it with AssertNoLeakFrom
|
||||||
|
// and an in-brain target.
|
||||||
|
func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) {
|
||||||
|
t.Helper()
|
||||||
|
AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
|
||||||
|
// a file elsewhere in the brain written with WriteSentinelAt.
|
||||||
|
func AssertNoLeakFrom(t *testing.T, path, inside, target string, d time.Duration, read func() string) {
|
||||||
|
t.Helper()
|
||||||
|
StartSwap(t, path, inside, target, false)
|
||||||
|
leaks, iters := 0, 0
|
||||||
|
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
|
||||||
|
leaks += strings.Count(read(), Sentinel)
|
||||||
|
}
|
||||||
|
if leaks > 0 {
|
||||||
|
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertNoHang loops read for d while a FIFO swapper (held writers) runs on
|
||||||
|
// path, and fails if a read does not return within timeout.
|
||||||
|
func AssertNoHang(t *testing.T, path, inside string, d, timeout time.Duration, read func()) {
|
||||||
|
t.Helper()
|
||||||
|
StartSwap(t, path, inside, Outside(t), true)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for deadline := time.Now().Add(d); time.Now().Before(deadline); {
|
||||||
|
read()
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatal("reader hung on a swapped-in FIFO with a writer held open")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,13 +42,19 @@ func (s *Store) WithGraph(g graphsync.Store) *Store {
|
|||||||
|
|
||||||
// Write creates a brain note and returns its read-after-write handle.
|
// Write creates a brain note and returns its read-after-write handle.
|
||||||
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||||
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
Content: n.Content,
|
Content: n.Content,
|
||||||
Filename: n.Filename,
|
Filename: n.Filename,
|
||||||
Type: n.Type,
|
Wing: n.Wing,
|
||||||
Domain: n.Domain,
|
Hall: n.Hall,
|
||||||
Wing: n.Wing,
|
Origin: n.Origin,
|
||||||
Hall: n.Hall,
|
WrittenBy: n.WrittenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return capture.Ref{}, err
|
return capture.Ref{}, err
|
||||||
@@ -57,14 +63,20 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
|
|||||||
s.indexInGraph(ctx, "brain_write", relPath)
|
s.indexInGraph(ctx, "brain_write", relPath)
|
||||||
|
|
||||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update supersedes an existing note in place. slug may be a bare slug
|
// Update supersedes an existing note in place. slug may be a bare slug
|
||||||
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||||
// contains a slash). It never creates — a missing target is an error.
|
// contains a slash). It never creates — a missing target is an error.
|
||||||
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason, Origin: n.Origin, WrittenBy: n.WrittenBy}
|
||||||
if strings.Contains(slug, "/") {
|
if strings.Contains(slug, "/") {
|
||||||
opts.Path = slug
|
opts.Path = slug
|
||||||
} else {
|
} else {
|
||||||
@@ -80,7 +92,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
|
|||||||
}
|
}
|
||||||
s.indexInGraph(ctx, "brain_update", relPath)
|
s.indexInGraph(ctx, "brain_update", relPath)
|
||||||
|
|
||||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fetches a note by id/path — the read-after-write confirmation
|
// Get fetches a note by id/path — the read-after-write confirmation
|
||||||
|
|||||||
@@ -83,3 +83,45 @@ func TestStoreGetRoundTripsHash(t *testing.T) {
|
|||||||
assert.Equal(t, "a", note.Frontmatter["wing"])
|
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||||
assert.Contains(t, note.Body, "# Body")
|
assert.Contains(t, note.Body, "# Body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "See [[target]] and [[secret-hygiene]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: cad-babysit.")
|
||||||
|
assert.NotContains(t, string(got), "[[cad-babysit]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Package capture is the Clean-Architecture use-case for the uniform
|
// Package capture is the Clean-Architecture use-case for the uniform
|
||||||
// capture capability (issue #49/#51): persist a finished session's
|
// capture capability (issue #49/#51): persist a finished session's
|
||||||
// valuable output — insights → brain, action items → Gitea tickets,
|
// valuable output — insights → brain, action items → Gitea tickets —
|
||||||
// optional summary → ai-sessions — with one invocation, identical core
|
// with one invocation, identical core behaviour across every harness.
|
||||||
// behaviour across every harness.
|
|
||||||
//
|
//
|
||||||
// This package is pure orchestration. It depends only on ports
|
// This package is pure orchestration. It depends only on ports
|
||||||
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||||
@@ -61,6 +60,7 @@ type CaptureContext struct {
|
|||||||
Classification string // caller-declared level token ("" = unspecified)
|
Classification string // caller-declared level token ("" = unspecified)
|
||||||
Principal string // server-derived (auth); audit identity
|
Principal string // server-derived (auth); audit identity
|
||||||
Origin Zone // server-derived trust zone; the I1 gate input
|
Origin Zone // server-derived trust zone; the I1 gate input
|
||||||
|
AuthPath string // server-derived: "static" or "jwt"; stamped as note provenance (#145)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insight is one piece of session knowledge bound for the brain. A
|
// Insight is one piece of session knowledge bound for the brain. A
|
||||||
@@ -83,19 +83,11 @@ type Ticket struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary is an optional session summary bound for ai-sessions.
|
|
||||||
type Summary struct {
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
ReposTouched []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptureInput is the whole capture request.
|
// CaptureInput is the whole capture request.
|
||||||
type CaptureInput struct {
|
type CaptureInput struct {
|
||||||
Context CaptureContext
|
Context CaptureContext
|
||||||
Insights []Insight
|
Insights []Insight
|
||||||
Tickets []Ticket
|
Tickets []Ticket
|
||||||
Summary *Summary
|
|
||||||
DryRun bool
|
DryRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +109,6 @@ type TicketResult struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryResult is the summary outcome in the receipt.
|
|
||||||
type SummaryResult struct {
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemError pins a failure to a specific request item for the partial
|
// ItemError pins a failure to a specific request item for the partial
|
||||||
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||||
type ItemError struct {
|
type ItemError struct {
|
||||||
@@ -136,7 +122,6 @@ type ItemError struct {
|
|||||||
type CaptureReceipt struct {
|
type CaptureReceipt struct {
|
||||||
Insights []InsightResult `json:"insights"`
|
Insights []InsightResult `json:"insights"`
|
||||||
Tickets []TicketResult `json:"tickets"`
|
Tickets []TicketResult `json:"tickets"`
|
||||||
Summary *SummaryResult `json:"summary,omitempty"`
|
|
||||||
Errors []ItemError `json:"errors"`
|
Errors []ItemError `json:"errors"`
|
||||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type Ref struct {
|
|||||||
Path string
|
Path string
|
||||||
ContentHash string
|
ContentHash string
|
||||||
Superseded bool
|
Superseded bool
|
||||||
|
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
|
||||||
|
// text because they resolve to no entry. Reported rather than rejected:
|
||||||
|
// see brain.StripUnresolvedLinks for why the write still lands.
|
||||||
|
Unlinked []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||||
@@ -27,18 +31,20 @@ type StoredNote struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
// Note is the brain-write payload: wing+hall are the only routing fields
|
||||||
// and the legacy type/domain fields so a single BrainStore serves both
|
// (the legacy type/domain params were removed — ADR-0012 / #79). Reason is
|
||||||
// capture insights and the existing MCP brain_write surface. Reason is
|
|
||||||
// the supersede rationale, used only by Update.
|
// the supersede rationale, used only by Update.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
Content string
|
Content string
|
||||||
Filename string
|
Filename string
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Type string
|
|
||||||
Domain string
|
|
||||||
Reason string
|
Reason string
|
||||||
|
// Provenance (hyperguild#145) is stamped by the server from the
|
||||||
|
// authenticated principal, never taken from Content's frontmatter.
|
||||||
|
// Origin is "static", "jwt" or "unknown"; empty means "unknown".
|
||||||
|
Origin string
|
||||||
|
WrittenBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrainStore is the brain persistence port — the shared implementation of
|
// BrainStore is the brain persistence port — the shared implementation of
|
||||||
@@ -69,11 +75,6 @@ type IssueTracker interface {
|
|||||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryWriter is the ai-sessions summary port.
|
|
||||||
type SummaryWriter interface {
|
|
||||||
WriteFile(ctx context.Context, repo, path, content string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClassificationPolicy derives a target's sensitivity (model C). The
|
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||||
// "stricter wins" combination of declared vs derived is use-case policy
|
// "stricter wins" combination of declared vs derived is use-case policy
|
||||||
// and lives in the service, so the port stays minimal. Satisfied by
|
// and lives in the service, so the port stays minimal. Satisfied by
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package capture
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,22 +12,19 @@ import (
|
|||||||
|
|
||||||
// Service is the CaptureSession use-case. It depends only on ports.
|
// Service is the CaptureSession use-case. It depends only on ports.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
brain BrainStore
|
brain BrainStore
|
||||||
issues IssueTracker
|
issues IssueTracker
|
||||||
summaries SummaryWriter
|
policy ClassificationPolicy
|
||||||
policy ClassificationPolicy
|
audit AuditSink
|
||||||
audit AuditSink
|
|
||||||
|
|
||||||
// now is the clock, injectable for deterministic summary paths and
|
// now is the clock, injectable for deterministic audit timestamps in
|
||||||
// audit timestamps in tests.
|
// tests.
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service from its ports. summaries may be nil
|
// NewService constructs a Service from its ports.
|
||||||
// when no summary persistence is wired; a CaptureInput with a Summary
|
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
// then fails that item rather than panicking.
|
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
|
||||||
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
|
||||||
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||||
@@ -88,7 +83,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
declared, _ = classification.ParseLevel(in.Context.Classification)
|
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||||
}
|
}
|
||||||
|
|
||||||
effective, securityEvents := s.resolveClassification(declared, in)
|
effective, securityEvents, escalators := s.resolveClassification(declared, in)
|
||||||
|
|
||||||
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||||
// label that names a different zone is descriptive-only and logged as a
|
// label that names a different zone is descriptive-only and logged as a
|
||||||
@@ -112,8 +107,8 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
Items: nil, // refused before any write
|
Items: nil, // refused before any write
|
||||||
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||||
}, AuditCentral)
|
}, AuditCentral)
|
||||||
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin (%s)",
|
||||||
ErrSovereigntyRefused, in.Context.Origin)
|
ErrSovereigntyRefused, in.Context.Origin, escalationReason(escalators))
|
||||||
}
|
}
|
||||||
|
|
||||||
receipt := CaptureReceipt{
|
receipt := CaptureReceipt{
|
||||||
@@ -131,9 +126,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
|
||||||
}
|
|
||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +142,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
var landed []string
|
var landed []string
|
||||||
|
|
||||||
for i, ins := range in.Insights {
|
for i, ins := range in.Insights {
|
||||||
res, item, err := s.persistInsight(ctx, ins)
|
res, item, err := s.persistInsight(ctx, ins, in.Context)
|
||||||
receipt.Insights = append(receipt.Insights, res)
|
receipt.Insights = append(receipt.Insights, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||||
@@ -169,16 +161,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.Summary != nil {
|
|
||||||
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
|
||||||
receipt.Summary = &res
|
|
||||||
if err != nil {
|
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
|
||||||
} else {
|
|
||||||
landed = append(landed, "summary:"+res.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// I5: persist the request-level audit record of exactly what landed,
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
// using the outcome reserved before the writes. AuditBuffered surfaces
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
// the degraded (locally-buffered) state on the receipt.
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
@@ -239,15 +221,28 @@ func (s *Service) validate(in CaptureInput) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveClassification computes the effective level (stricter of
|
// resolveClassification computes the effective level (stricter of declared
|
||||||
// declared and every target's derived level) and collects a security
|
// and every target's derived level), collects a security event whenever the
|
||||||
// event whenever the caller under-declared relative to a target floor.
|
// caller under-declared relative to a target floor, and names the targets
|
||||||
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
// that carry the strictest level.
|
||||||
|
//
|
||||||
|
// That third return is what makes an I1 refusal actionable. A caller sending
|
||||||
|
// twenty tickets gets one refusal; without the offending target it cannot
|
||||||
|
// tell which repo tripped the gate, and infra#265 shows what happens next —
|
||||||
|
// the caller concluded the tickets ARRAY was broken, built a fallback of
|
||||||
|
// twenty individual calls, and filed a bug against the wrong thing.
|
||||||
|
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string, []string) {
|
||||||
effective := declared
|
effective := declared
|
||||||
var events []string
|
var events []string
|
||||||
|
derivedFor := map[string]classification.Level{}
|
||||||
|
var order []string
|
||||||
consider := func(kind classification.TargetKind, name string) {
|
consider := func(kind classification.TargetKind, name string) {
|
||||||
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||||
effective = classification.Stricter(effective, derived)
|
effective = classification.Stricter(effective, derived)
|
||||||
|
if _, seen := derivedFor[name]; !seen {
|
||||||
|
derivedFor[name] = derived
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
if declared < derived {
|
if declared < derived {
|
||||||
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||||
declared, name, kindString(kind), derived))
|
declared, name, kindString(kind), derived))
|
||||||
@@ -259,16 +254,35 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
consider(classification.RepoTarget, tk.Repo)
|
consider(classification.RepoTarget, tk.Repo)
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
for _, repo := range in.Summary.ReposTouched {
|
// Only targets that actually reach the effective level are named: at
|
||||||
consider(classification.RepoTarget, repo)
|
// twenty tickets, listing the innocent ones is noise that hides the one
|
||||||
|
// that matters.
|
||||||
|
var escalators []string
|
||||||
|
for _, name := range order {
|
||||||
|
if derivedFor[name] == effective && effective > declared {
|
||||||
|
escalators = append(escalators, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return effective, events
|
return effective, events, escalators
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
// escalationReason renders why the effective classification landed where it
|
||||||
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
// did, for an operator reading a refusal. Naming the caller's own declaration
|
||||||
|
// matters as much as naming a target: "you asked for this" and "a repo you
|
||||||
|
// touched required it" call for different fixes.
|
||||||
|
func escalationReason(escalators []string) string {
|
||||||
|
if len(escalators) == 0 {
|
||||||
|
return "declared by the caller; no target required it"
|
||||||
|
}
|
||||||
|
return "required by: " + strings.Join(escalators, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistInsight(ctx context.Context, ins Insight, cc CaptureContext) (InsightResult, string, error) {
|
||||||
|
note := Note{
|
||||||
|
Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text)),
|
||||||
|
Origin: cc.AuthPath, WrittenBy: cc.Principal,
|
||||||
|
}
|
||||||
var ref Ref
|
var ref Ref
|
||||||
var err error
|
var err error
|
||||||
if ins.SupersedeSlug != "" {
|
if ins.SupersedeSlug != "" {
|
||||||
@@ -309,60 +323,6 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
|
||||||
if s.summaries == nil {
|
|
||||||
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
|
||||||
}
|
|
||||||
path := s.summaryPath(c, sum)
|
|
||||||
content := s.renderSummary(c, sum)
|
|
||||||
repo := "ai-sessions"
|
|
||||||
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
|
||||||
return SummaryResult{Path: path, OK: false}, err
|
|
||||||
}
|
|
||||||
return SummaryResult{Path: path, OK: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
|
||||||
// The ref8 disambiguator is derived from the session_ref (or the title
|
|
||||||
// when no ref is present) so distinct sessions never collide.
|
|
||||||
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
|
||||||
t := s.now().UTC()
|
|
||||||
slug := brain.Sanitise(sum.Title)
|
|
||||||
if slug == "" {
|
|
||||||
slug = "summary"
|
|
||||||
}
|
|
||||||
seed := c.SessionRef
|
|
||||||
if seed == "" {
|
|
||||||
seed = sum.Title + sum.Body
|
|
||||||
}
|
|
||||||
sum8 := shortHash(seed)
|
|
||||||
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
|
||||||
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
|
||||||
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
|
||||||
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("---\n")
|
|
||||||
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
|
||||||
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
|
||||||
if c.SessionRef != "" {
|
|
||||||
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
|
||||||
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
|
||||||
if len(sum.ReposTouched) > 0 {
|
|
||||||
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
|
||||||
}
|
|
||||||
b.WriteString("---\n\n")
|
|
||||||
b.WriteString(sum.Body)
|
|
||||||
if !strings.HasSuffix(sum.Body, "\n") {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func kindString(k classification.TargetKind) string {
|
func kindString(k classification.TargetKind) string {
|
||||||
if k == classification.RepoTarget {
|
if k == classification.RepoTarget {
|
||||||
return "repo"
|
return "repo"
|
||||||
@@ -381,8 +341,3 @@ func firstLine(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortHash(s string) string {
|
|
||||||
sum := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(sum[:])[:8]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package capture
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -59,12 +60,18 @@ type fakeTracker struct {
|
|||||||
closed []int
|
closed []int
|
||||||
comments []int
|
comments []int
|
||||||
err error
|
err error
|
||||||
|
// failOnTitle makes CreateIssue fail for one specific title, so a
|
||||||
|
// partial-failure receipt can be exercised without failing every item.
|
||||||
|
failOnTitle string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return IssueRef{}, f.err
|
return IssueRef{}, f.err
|
||||||
}
|
}
|
||||||
|
if f.failOnTitle != "" && title == f.failOnTitle {
|
||||||
|
return IssueRef{}, errors.New("tracker refused this one")
|
||||||
|
}
|
||||||
f.created = append(f.created, repo+":"+title)
|
f.created = append(f.created, repo+":"+title)
|
||||||
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||||
}
|
}
|
||||||
@@ -85,24 +92,11 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _
|
|||||||
return IssueRef{Repo: repo, Number: number}, nil
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeSummary struct {
|
|
||||||
paths []string
|
|
||||||
content []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
|
||||||
if f.err != nil {
|
|
||||||
return f.err
|
|
||||||
}
|
|
||||||
f.paths = append(f.paths, path)
|
|
||||||
f.content = append(f.content, content)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakePolicy derives from an explicit map; default Internal so tests pin
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||||
// behaviour without depending on the real defaulting.
|
// behaviour without depending on the real defaulting.
|
||||||
type fakePolicy struct{ tags map[string]classification.Level }
|
type fakePolicy struct {
|
||||||
|
tags map[string]classification.Level
|
||||||
|
}
|
||||||
|
|
||||||
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||||
if lvl, ok := p.tags[t.Name]; ok {
|
if lvl, ok := p.tags[t.Name]; ok {
|
||||||
@@ -135,8 +129,8 @@ func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) erro
|
|||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
s := NewService(b, tr, sw, p, a)
|
s := NewService(b, tr, p, a)
|
||||||
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -151,7 +145,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -180,7 +174,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -197,7 +191,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -216,7 +210,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||||
@@ -239,7 +233,7 @@ func TestCapturePartialFailureBestEffort(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -264,7 +258,7 @@ func TestCaptureDryRunWritesNothing(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -287,7 +281,7 @@ func TestCaptureStricterClassificationWins(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(b, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "internal"
|
ctx.Classification = "internal"
|
||||||
@@ -306,7 +300,7 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -319,26 +313,6 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
|
||||||
sw := &fakeSummary{}
|
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
|
||||||
|
|
||||||
ctx := baseCtx()
|
|
||||||
ctx.Fidelity = "transcript-parse"
|
|
||||||
ctx.SessionRef = "abc123def456"
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
||||||
Context: ctx,
|
|
||||||
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, rec.Summary)
|
|
||||||
assert.True(t, rec.Summary.OK)
|
|
||||||
require.Len(t, sw.paths, 1)
|
|
||||||
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
|
||||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
|
||||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- I1 sovereignty gate (#53) ---
|
// --- I1 sovereignty gate (#53) ---
|
||||||
|
|
||||||
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
@@ -346,7 +320,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, tr, nil, pol, au)
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -368,7 +342,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -387,7 +361,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Harness = "sovereign-soil" // asserted
|
ctx.Harness = "sovereign-soil" // asserted
|
||||||
@@ -407,7 +381,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||||
// us-nexus origin is fine for non-confidential data.
|
// us-nexus origin is fine for non-confidential data.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
@@ -426,7 +400,7 @@ func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -443,7 +417,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
// proceeds and the receipt flags the degraded audit state.
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{reserveMode: AuditBuffered}
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -458,7 +432,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -469,3 +443,98 @@ func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
|||||||
assert.True(t, rec.DryRun)
|
assert.True(t, rec.DryRun)
|
||||||
assert.Empty(t, au.entries)
|
assert.Empty(t, au.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- hyperguild#91 part 1 -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The reported symptom was "capture with a tickets array fails" (infra#265:
|
||||||
|
// 20 tickets errored, 5 errored, caller fell back to 20 individual
|
||||||
|
// issue_create calls). The array was never the failing dimension — every
|
||||||
|
// ticket targeted a client repo, and the I1 gate refuses that through a
|
||||||
|
// us-nexus origin at ANY length. The refusal did not say which target
|
||||||
|
// escalated the classification, so it read as a malfunction.
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthOne(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "one", Body: "b"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 1)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Len(t, tr.created, 1)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthMany(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
in := CaptureInput{Context: baseCtx()}
|
||||||
|
for i := range 20 {
|
||||||
|
in.Tickets = append(in.Tickets, Ticket{
|
||||||
|
Repo: "hyperguild", Action: "create", Title: fmt.Sprintf("t%02d", i), Body: "b",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 20, "one receipt entry per ticket")
|
||||||
|
for i, r := range rec.Tickets {
|
||||||
|
assert.True(t, r.OK, "ticket %d failed: %+v", i, rec.Errors)
|
||||||
|
}
|
||||||
|
assert.Len(t, tr.created, 20)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_OneFailingTicketDoesNotSinkTheRest(t *testing.T) {
|
||||||
|
// infra#265 asked explicitly: "if ticket 3 of 5 fails, what happened to
|
||||||
|
// 1 and 2?" They land, and the receipt says so per item.
|
||||||
|
b, au := &fakeBrain{}, &fakeAudit{}
|
||||||
|
tr := &fakeTracker{failOnTitle: "boom"}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-1", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "boom", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-3", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "a per-item failure is a partial receipt, not a call error")
|
||||||
|
require.Len(t, rec.Tickets, 3)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.False(t, rec.Tickets[1].OK)
|
||||||
|
assert.True(t, rec.Tickets[2].OK)
|
||||||
|
require.Len(t, rec.Errors, 1)
|
||||||
|
assert.Contains(t, rec.Errors[0].Item, "ticket[1]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_I1RefusalNamesWhatEscalatedTheClassification(t *testing.T) {
|
||||||
|
// The actual defect behind infra#265. Without the offending target in
|
||||||
|
// the message, a caller with 20 tickets cannot tell which repo tripped
|
||||||
|
// the gate — and reasonably concludes the array itself is broken.
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"trucker-ecommerce": classification.Confidential}}
|
||||||
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Origin = ZoneUSNexus
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "fine", Body: "b"},
|
||||||
|
{Repo: "trucker-ecommerce", Action: "create", Title: "client work", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
assert.Contains(t, err.Error(), "trucker-ecommerce",
|
||||||
|
"the refusal must name the target that escalated the classification")
|
||||||
|
assert.NotContains(t, err.Error(), "hyperguild",
|
||||||
|
"only the offending target, or the message is noise at 20 tickets")
|
||||||
|
assert.Empty(t, tr.created, "refused before any write")
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type request struct {
|
|||||||
Context contextBody `json:"context"`
|
Context contextBody `json:"context"`
|
||||||
Insights []insightBody `json:"insights"`
|
Insights []insightBody `json:"insights"`
|
||||||
Tickets []ticketBody `json:"tickets"`
|
Tickets []ticketBody `json:"tickets"`
|
||||||
Summary *summaryBody `json:"summary,omitempty"`
|
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +81,6 @@ type ticketBody struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summaryBody struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
ReposTouched []string `json:"repos_touched,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
// result to an HTTP status.
|
// result to an HTTP status.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -111,6 +104,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// caller may have tried to put in the body.
|
// caller may have tried to put in the body.
|
||||||
in.Context.Principal = principal
|
in.Context.Principal = principal
|
||||||
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
||||||
|
in.Context.AuthPath = "jwt"
|
||||||
|
if viaStatic {
|
||||||
|
in.Context.AuthPath = "static"
|
||||||
|
}
|
||||||
|
|
||||||
rec, err := h.svc.Capture(r.Context(), in)
|
rec, err := h.svc.Capture(r.Context(), in)
|
||||||
switch {
|
switch {
|
||||||
@@ -183,11 +180,6 @@ func (b request) toInput() capture.CaptureInput {
|
|||||||
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if b.Summary != nil {
|
|
||||||
in.Summary = &capture.Summary{
|
|
||||||
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +196,6 @@ func statusFor(rec capture.CaptureReceipt) int {
|
|||||||
for _, t := range rec.Tickets {
|
for _, t := range rec.Tickets {
|
||||||
count(&ok, &fail, t.OK)
|
count(&ok, &fail, t.OK)
|
||||||
}
|
}
|
||||||
if rec.Summary != nil {
|
|
||||||
count(&ok, &fail, rec.Summary.OK)
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case fail == 0:
|
case fail == 0:
|
||||||
return http.StatusOK
|
return http.StatusOK
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ func (f fakeTracker) CreateIssue(context.Context, string, string, string) (captu
|
|||||||
}
|
}
|
||||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
|
||||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ type refusingAudit struct{}
|
|||||||
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||||
return 0, errors.New("central audit sink unreachable")
|
return 0, errors.New("central audit sink unreachable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -190,7 +193,7 @@ func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOu
|
|||||||
func TestAuditUnavailableIs503(t *testing.T) {
|
func TestAuditUnavailableIs503(t *testing.T) {
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
|
||||||
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package capturehttp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The REST capture door derives AuthPath itself (hyperguild#145); a body
|
||||||
|
// field cannot set it, and static vs JWT are told apart.
|
||||||
|
func TestRESTCaptureStampsAuthPathFromAuthentication(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, authz, wantOrigin, wantBy string
|
||||||
|
v capturehttp.Validator
|
||||||
|
sovereign []string
|
||||||
|
}{
|
||||||
|
{"static", "Bearer " + staticTok, "origin: static\n", "written_by: local-cli\n", nil, nil},
|
||||||
|
{"jwt", "Bearer jwt", "origin: jwt\n", "written_by: koala-cli\n", fakeValidator{subject: "koala-cli"}, []string{"koala-cli"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
cfg, err := classification.Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(brainDir), fakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
|
h := capturehttp.New(svc, tc.v, staticTok, "local-cli", capturehttp.NewOriginResolver(tc.sovereign))
|
||||||
|
rr := do(t, h, tc.authz, map[string]any{
|
||||||
|
"context": map[string]any{
|
||||||
|
"harness": "claude-code", "actor": "mathias", "classification": "internal",
|
||||||
|
"AuthPath": "static", "auth_path": "static", "principal": "local-cli",
|
||||||
|
},
|
||||||
|
"insights": []map[string]any{{"text": "rest provenance probe", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code, rr.Body.String())
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
|
||||||
|
require.Len(t, matches, 1)
|
||||||
|
b, err := os.ReadFile(matches[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(b), tc.wantOrigin)
|
||||||
|
assert.Contains(t, string(b), tc.wantBy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Package claim validates the claim-property frontmatter that says when and
|
||||||
|
// where a note's claim holds, and who decided that (brain#21).
|
||||||
|
//
|
||||||
|
// Two families were designed together and land separately. This is the
|
||||||
|
// applicability envelope:
|
||||||
|
//
|
||||||
|
// half_life: volatile | seasonal | durable — how fast it decays
|
||||||
|
// applies: local | homelab | universal — how widely it holds
|
||||||
|
// claim_provenance: asserted | inferred — who set the above
|
||||||
|
//
|
||||||
|
// The strength family (confidence, contested) follows once the ingest-time
|
||||||
|
// contradiction detector exists to populate it.
|
||||||
|
//
|
||||||
|
// Why the envelope is validated rather than merely accepted: a value outside
|
||||||
|
// the enum is worse than an absent one. A lint skips what it cannot parse, so
|
||||||
|
// the note reads as classified while nothing acts on it — the field becomes
|
||||||
|
// decoration that suppresses the very review it was added to trigger.
|
||||||
|
//
|
||||||
|
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
|
||||||
|
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
|
||||||
|
// on the same key. The shipped schema keeps the name; this one takes another.
|
||||||
|
package claim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field names, exported so writers and lints agree on the spelling.
|
||||||
|
const (
|
||||||
|
FieldHalfLife = "half_life"
|
||||||
|
FieldApplies = "applies"
|
||||||
|
FieldProvenance = "claim_provenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
halfLives = []string{"volatile", "seasonal", "durable"}
|
||||||
|
appliesTo = []string{"local", "homelab", "universal"}
|
||||||
|
provenances = []string{"asserted", "inferred"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
|
||||||
|
//
|
||||||
|
// Absent fields are valid: the envelope is opt-in for new writes, and a
|
||||||
|
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
|
||||||
|
// 1113 notes' properties in bulk has the same failure mode as the extraction
|
||||||
|
// pass that produced 28 wrong wikilinks out of 67.
|
||||||
|
func ValidateEnvelope(fm map[string]string) error {
|
||||||
|
halfLife, hasHalfLife := present(fm, FieldHalfLife)
|
||||||
|
applies, hasApplies := present(fm, FieldApplies)
|
||||||
|
prov, hasProv := present(fm, FieldProvenance)
|
||||||
|
|
||||||
|
if hasHalfLife {
|
||||||
|
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasApplies {
|
||||||
|
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasProv {
|
||||||
|
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field and its provenance travel together or not at all. A value
|
||||||
|
// whose author is unknown cannot be trusted differently from one Mathias
|
||||||
|
// asserted, which is how an inferred guess hardens into accepted fact.
|
||||||
|
switch {
|
||||||
|
case (hasHalfLife || hasApplies) && !hasProv:
|
||||||
|
return fmt.Errorf("%s is required when %s or %s is set",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
case hasProv && !hasHalfLife && !hasApplies:
|
||||||
|
return fmt.Errorf("%s is set but neither %s nor %s is",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// present reports a trimmed field value and whether it was set to anything
|
||||||
|
// non-empty. A key written with an empty value is treated as absent rather
|
||||||
|
// than as an error: that is a caller emitting a placeholder, not asserting.
|
||||||
|
func present(fm map[string]string, key string) (string, bool) {
|
||||||
|
v := strings.TrimSpace(fm[key])
|
||||||
|
return v, v != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneOf(field, value string, allowed []string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package claim_test covers the claim-property envelope (brain#21).
|
||||||
|
package claim_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvelope(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fm map[string]string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absent is fine — the fields are opt-in on new writes",
|
||||||
|
fm: map[string]string{"wing": "homelab", "hall": "facts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete envelope with provenance",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inferred provenance is equally valid — it just must be stated",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A value nobody can act on is worse than no value: a lint
|
||||||
|
// silently skips it, so the note looks classified and isn't.
|
||||||
|
name: "unknown half_life value",
|
||||||
|
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown applies value",
|
||||||
|
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// brain#21: a value a model inferred must never be
|
||||||
|
// indistinguishable from one Mathias asserted. Writing the field
|
||||||
|
// without saying who set it is exactly that laundering.
|
||||||
|
name: "envelope field without provenance",
|
||||||
|
fm: map[string]string{"half_life": "durable"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "applies without provenance",
|
||||||
|
fm: map[string]string{"applies": "homelab"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provenance value",
|
||||||
|
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
|
||||||
|
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Provenance alone says who set fields that aren't there.
|
||||||
|
name: "provenance without any envelope field",
|
||||||
|
fm: map[string]string{"claim_provenance": "asserted"},
|
||||||
|
wantErr: "claim_provenance is set but neither half_life nor applies is",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The glossary wing has used scope: homelab-core since #5. The
|
||||||
|
// envelope deliberately does NOT claim that key (brain#21).
|
||||||
|
name: "glossary scope is untouched",
|
||||||
|
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := claim.ValidateEnvelope(tt.fm)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,26 @@ var DefaultRules = []Rule{
|
|||||||
// JSON, so both segments begin with "eyJ".
|
// JSON, so both segments begin with "eyJ".
|
||||||
{Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)},
|
{Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)},
|
||||||
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
|
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
|
||||||
|
// Any credential embedded in a URL's userinfo, which is what a git credential
|
||||||
|
// store holds: `https://<user>:<token>@host`. The postgres rule above is the
|
||||||
|
// same shape narrowed to one scheme, and is kept ahead of this one only so a
|
||||||
|
// DSN reports the more specific name -- same ordering convention as
|
||||||
|
// authorization-header before bearer-token.
|
||||||
|
//
|
||||||
|
// Added after this shape passed ALL rules and was therefore ingested rather
|
||||||
|
// than dropped (hyperguild#142). It reached ~/.claude/projects -- this
|
||||||
|
// watcher's own input -- because a test suite in mathias/infra executed a
|
||||||
|
// script that configures git while inheriting the operator's real $HOME, read
|
||||||
|
// koala's live credential store, and printed it. Two credentials were rotated
|
||||||
|
// (infra#466). The scrubber is the control that decides whether that class of
|
||||||
|
// mistake is survivable, and for this shape it was failing open.
|
||||||
|
//
|
||||||
|
// The {8,} floor on the secret half is what keeps `https://host:443/path` and
|
||||||
|
// `https://user:@host` out; the `[^:\s/@]+` user half stops a host:port pair
|
||||||
|
// being read as userinfo. Both directions are covered by
|
||||||
|
// TestScrub_URLUserinfoCredential, because a rule that dropped every URL with
|
||||||
|
// a colon in it would discard most of the signal this watcher exists to keep.
|
||||||
|
{Name: "url-userinfo-credential", RE: regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^:\s/@]+:[^@\s/]{8,}@`)},
|
||||||
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
|
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
|
||||||
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
|
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
|
||||||
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
||||||
|
|||||||
@@ -46,6 +46,64 @@ func TestScrub_PoisonedFixtures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A credential embedded in a URL's userinfo is the shape a git credential store
|
||||||
|
// holds -- `https://<user>:<token>@host` -- and it walked past every rule until
|
||||||
|
// hyperguild#142. There was a rule for `postgres://user:pass@` and nothing for the
|
||||||
|
// general form, so `~/.git-credentials` content was ingested rather than dropped.
|
||||||
|
//
|
||||||
|
// Found the hard way: a test suite in mathias/infra executed a script that
|
||||||
|
// configures git while inheriting the operator's real $HOME, read koala's live
|
||||||
|
// credential store, and printed two live credentials into a session transcript.
|
||||||
|
// ~/.claude/projects is this watcher's own input, so the scrubber was the last
|
||||||
|
// line of defence for that mistake and it failed open. Two credentials were
|
||||||
|
// rotated (infra#466).
|
||||||
|
//
|
||||||
|
// The lesson is not "do not read the credential file" -- that mistake will recur.
|
||||||
|
// It is that this shape must fail closed, because the scrubber is what decides
|
||||||
|
// whether such a mistake is survivable.
|
||||||
|
func TestScrub_URLUserinfoCredential(t *testing.T) {
|
||||||
|
// Every secret below is INVENTED. None is or was a real credential.
|
||||||
|
mustScrub := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{"git-credential-store-user", "https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||||
|
{"git-credential-store-oauth2", "https://oauth2:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||||
|
{"in-context-payload-line", `payload line: https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be`},
|
||||||
|
{"clone-url-in-argv", "git clone https://oauth2:abcdefghijklmnopqrstuvwx@git.d-ma.be/mathias/infra.git"},
|
||||||
|
{"http-not-https", "http://admin:supersecretvalue123@10.0.1.20/api"},
|
||||||
|
{"git-plus-ssh-scheme", "git+ssh://deploy:abcdefghijklmnop@example.com/repo"},
|
||||||
|
{"redis-scheme", "redis://default:abcdefghijklmnopqrst@10.0.1.20:6379/0"},
|
||||||
|
}
|
||||||
|
for _, tc := range mustScrub {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.NotEmpty(t, Scrub(tc.content),
|
||||||
|
"a credential in URL userinfo must be dropped, not ingested")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction matters as much: a rule that scrubs every URL with a
|
||||||
|
// colon in it would drop most of the signal this watcher exists to keep.
|
||||||
|
mustNotScrub := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{"host-with-port", "cloning https://git.d-ma.be:443/mathias/infra.git"},
|
||||||
|
{"postgres-no-password", "postgres://localhost:5432/brain"},
|
||||||
|
{"url-with-colon-in-query", "GET https://example.com/path?filter=a:b&x=1"},
|
||||||
|
{"plain-url", "see https://git.d-ma.be/mathias/infra/issues/466"},
|
||||||
|
{"userinfo-no-secret", "https://user:@example.com"},
|
||||||
|
{"format-string-placeholder", `printf 'https://oauth2:%s@%s\n' "$TOKEN" "$HOST"`},
|
||||||
|
{"scheme-relative-time", "duration https://example.com took 00:03:22"},
|
||||||
|
}
|
||||||
|
for _, tc := range mustNotScrub {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Empty(t, Scrub(tc.content),
|
||||||
|
"this is not a credential and must pass through")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingHandler captures log records so a test can assert that a file was
|
||||||
|
// never even attempted — the absence of a read error is the evidence.
|
||||||
|
type recordingHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(r.Message)
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
sb.WriteString(" " + a.Key + "=" + a.Value.String())
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
h.records = append(h.records, sb.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||||
|
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
||||||
|
|
||||||
|
func (h *recordingHandler) contains(substr string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for _, r := range h.records {
|
||||||
|
if strings.Contains(r, substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(t *testing.T, root, project, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, project)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
return writeSession(t, dir, id, []string{
|
||||||
|
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
|
||||||
|
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named project is not ingested, and its neighbours still are.
|
||||||
|
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globs are necessary, not a convenience. A project directory is derived from
|
||||||
|
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
|
||||||
|
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
|
||||||
|
// exact-match list could never keep up with those.
|
||||||
|
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of skipping at the directory walk: the file is never OPENED, so
|
||||||
|
// its content never enters this process. Proven by making it unreadable — a
|
||||||
|
// skipped file produces no read error because nothing tried to read it, while
|
||||||
|
// the same file in a non-skipped project does.
|
||||||
|
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||||
|
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
Logger: slog.New(rec),
|
||||||
|
SkipProjects: []string{"*cobalt-dingo*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.Empty(t, sink.batches)
|
||||||
|
assert.False(t, rec.contains("file failed"),
|
||||||
|
"a skipped file must not even be attempted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control test for the one above: without the skip, the unreadable file IS
|
||||||
|
// attempted and fails. Otherwise the assertion above could pass for the wrong
|
||||||
|
// reason.
|
||||||
|
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||||
|
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: &memSink{},
|
||||||
|
Logger: slog.New(rec),
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.True(t, rec.contains("file failed"),
|
||||||
|
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns match the project directory NAME, not the whole path. A temp
|
||||||
|
// directory or a home directory component must not accidentally match and
|
||||||
|
// silently disable ingestion.
|
||||||
|
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
// tmp is somewhere under /tmp; this must not match the project name.
|
||||||
|
SkipProjects: []string{"*tmp*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unparseable pattern must stop the watcher rather than be ignored. A
|
||||||
|
// silently dropped skip rule means ingesting what was meant to be excluded,
|
||||||
|
// which is the failure that cannot be noticed after the fact.
|
||||||
|
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
err := TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"[unclosed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[unclosed")
|
||||||
|
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The difference between SkipDir and filtering after the walk, made
|
||||||
|
// observable: an unreadable skipped directory must not disturb anything.
|
||||||
|
//
|
||||||
|
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
|
||||||
|
// error, and that error aborts the ENTIRE walk — so one skipped project with
|
||||||
|
// awkward permissions would silently stop ingesting every other project.
|
||||||
|
// Returning SkipDir means its contents are never listed at all.
|
||||||
|
//
|
||||||
|
// Added after a mutation test showed the earlier assertions could not tell the
|
||||||
|
// two implementations apart.
|
||||||
|
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
|
||||||
|
require.NoError(t, os.MkdirAll(blocked, 0o755))
|
||||||
|
_ = writeSession(t, blocked, "secret", []string{
|
||||||
|
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, os.Chmod(blocked, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"*cobalt-dingo*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
}
|
||||||
@@ -45,6 +45,25 @@ type Config struct {
|
|||||||
Sink Sink
|
Sink Sink
|
||||||
Cursors *CursorStore
|
Cursors *CursorStore
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
// SkipProjects excludes whole project directories from ingestion, as
|
||||||
|
// filepath.Match globs tested against the project directory NAME (the
|
||||||
|
// basename, e.g. "-home-mathias-dev"), not the full path.
|
||||||
|
//
|
||||||
|
// Globs rather than exact names because a project directory is derived
|
||||||
|
// from the session's working directory, so a git worktree yields an
|
||||||
|
// ephemeral name like
|
||||||
|
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
|
||||||
|
// exact-match list could keep up with.
|
||||||
|
//
|
||||||
|
// Matching directories are skipped during the directory WALK, so their
|
||||||
|
// files are never opened and their content never enters this process —
|
||||||
|
// which is a stronger property than filtering it out afterwards.
|
||||||
|
//
|
||||||
|
// An unparseable pattern stops the watcher. A silently dropped skip rule
|
||||||
|
// means ingesting what was meant to be excluded, and that is not
|
||||||
|
// noticeable after the fact.
|
||||||
|
SkipProjects []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||||
@@ -68,6 +87,9 @@ func Watch(ctx context.Context, cfg Config) error {
|
|||||||
if cfg.Logger == nil {
|
if cfg.Logger == nil {
|
||||||
cfg.Logger = slog.Default()
|
cfg.Logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cfg.Logger.Info("claudewatcher: started",
|
cfg.Logger.Info("claudewatcher: started",
|
||||||
"sessions_dir", cfg.SessionsDir,
|
"sessions_dir", cfg.SessionsDir,
|
||||||
"host", cfg.Host,
|
"host", cfg.Host,
|
||||||
@@ -91,7 +113,7 @@ func Watch(ctx context.Context, cfg Config) error {
|
|||||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||||
// TickOnce.
|
// TickOnce.
|
||||||
func runTick(ctx context.Context, cfg Config) {
|
func runTick(ctx context.Context, cfg Config) {
|
||||||
files, err := listSessionFiles(cfg.SessionsDir)
|
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||||
return
|
return
|
||||||
@@ -119,17 +141,52 @@ func TickOnce(ctx context.Context, cfg Config) error {
|
|||||||
if cfg.Logger == nil {
|
if cfg.Logger == nil {
|
||||||
cfg.Logger = slog.Default()
|
cfg.Logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
runTick(ctx, cfg)
|
runTick(ctx, cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSessionFiles(root string) ([]string, error) {
|
// validateSkipPatterns rejects a pattern filepath.Match cannot parse.
|
||||||
|
//
|
||||||
|
// Checked up front rather than per file: a bad pattern that only failed at
|
||||||
|
// match time would be reported once per session file per tick, and — worse —
|
||||||
|
// filepath.Match returns an error alongside a false match, so the directory
|
||||||
|
// would be ingested while the log complained.
|
||||||
|
func validateSkipPatterns(patterns []string) error {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if _, err := filepath.Match(p, "probe"); err != nil {
|
||||||
|
return fmt.Errorf("invalid skip-project pattern %q: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipProject reports whether a project directory name matches any pattern.
|
||||||
|
func skipProject(name string, patterns []string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
// The error case is already excluded by validateSkipPatterns.
|
||||||
|
if ok, _ := filepath.Match(p, name); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessionFiles(root string, skip []string, log *slog.Logger) ([]string, error) {
|
||||||
var out []string
|
var out []string
|
||||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
|
// SkipDir, so nothing inside is even listed. The files are never
|
||||||
|
// opened and their content never reaches this process.
|
||||||
|
if path != root && skipProject(d.Name(), skip) {
|
||||||
|
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(path, ".jsonl") {
|
if !strings.HasSuffix(path, ".jsonl") {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const extractPkg = "github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
|
|
||||||
|
// parseNonTest parses every non-test Go file under the module.
|
||||||
|
func parseNonTest(t *testing.T) map[string]*ast.File {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
out := map[string]*ast.File{}
|
||||||
|
err := filepath.WalkDir(filepath.Join("..", ".."), func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() && (d.Name() == "vendor" || d.Name() == ".git") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, perr := parser.ParseFile(fset, p, nil, 0)
|
||||||
|
if perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
out[filepath.ToSlash(p)] = f
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR #156 review: reading a caller-chosen path by name reopens it after the
|
||||||
|
// containment check (hyperguild#153 item 3). Outside this package, nothing
|
||||||
|
// may reference extract.Text — called OR taken as a value — under whatever
|
||||||
|
// name the package is imported (round 2: an alias and a func value slipped
|
||||||
|
// past a plain text search).
|
||||||
|
func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
|
||||||
|
for path, f := range parseNonTest(t) {
|
||||||
|
if strings.Contains(path, "internal/extract/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, imp := range f.Imports {
|
||||||
|
if p, _ := strconv.Unquote(imp.Path.Value); p == extractPkg {
|
||||||
|
name := "extract"
|
||||||
|
if imp.Name != nil {
|
||||||
|
name = imp.Name.Name
|
||||||
|
}
|
||||||
|
if name == "." {
|
||||||
|
t.Errorf("%s dot-imports extract; import it by name so its use stays checkable", path)
|
||||||
|
}
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||||
|
if id, ok := sel.X.(*ast.Ident); ok && names[id.Name] && sel.Sel.Name == "Text" {
|
||||||
|
t.Errorf("%s references %s.Text; use extract.TextIn through an os.Root", path, id.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR #156 review round 2: the FIFO fix (open non-blocking, check the handle)
|
||||||
|
// lives in OpenRegular, and reverting any caller to a plain Root.Open stayed
|
||||||
|
// green. In the three raw/ reader FILES parsed below (and only those: a
|
||||||
|
// wrapper in another file is not seen), no Open/OpenFile/ReadFile selector
|
||||||
|
// may appear outside OpenRegular, called or taken as a method value. This is
|
||||||
|
// a second layer; the behavioural race tests in internal/api and
|
||||||
|
// internal/watcher are what catch a rewrite through any API (round 3).
|
||||||
|
// osOpenAllowed names the functions in guarded files that may call os.Open*
|
||||||
|
// on a path that is not a raw/ read.
|
||||||
|
var osOpenAllowed = map[string]bool{
|
||||||
|
"internal/watcher/watcher.go:appendWatcherLog": true, // ingest log append, not a raw/ read
|
||||||
|
// Text reads by path; outside this package only TextIn's temp copy reaches
|
||||||
|
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
|
||||||
|
"internal/extract/extract.go:Text": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawReaderFilesUseNoOpenOrReadFileOutsideOpenRegular(t *testing.T) {
|
||||||
|
guarded := []string{"internal/api/rawroot.go", "internal/extract/extract.go", "internal/watcher/watcher.go"}
|
||||||
|
files := parseNonTest(t)
|
||||||
|
for _, g := range guarded {
|
||||||
|
var f *ast.File
|
||||||
|
for p, af := range files {
|
||||||
|
if strings.HasSuffix(p, g) {
|
||||||
|
f = af
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
t.Fatalf("guarded file %s not found", g)
|
||||||
|
}
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name == "OpenRegular" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(fn, func(n ast.Node) bool {
|
||||||
|
sel, ok := n.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel.Sel.Name == "Open" || sel.Sel.Name == "OpenFile" || sel.Sel.Name == "ReadFile" {
|
||||||
|
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "os" && osOpenAllowed[g+":"+fn.Name.Name] {
|
||||||
|
return true // a named non-raw write (the ingest log append)
|
||||||
|
}
|
||||||
|
t.Errorf("%s: %s calls .%s; raw/ reads must go through OpenRegular", g, fn.Name.Name, sel.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// ingestion/internal/extract/docmark.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// docmarkRequest is a JSON-RPC 2.0 tools/call request for docmark's
|
||||||
|
// convert_to_markdown tool.
|
||||||
|
type docmarkRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments struct {
|
||||||
|
ContentBase64 string `json:"content_base64"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
} `json:"arguments"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type docmarkResponse struct {
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractViaDocmark converts path (PDF/DOCX/XLSX/PPTX/image) to Markdown by
|
||||||
|
// calling the docmark MCP server with a single self-contained tools/call
|
||||||
|
// request (docmark runs stateless_http -- no initialize handshake or session
|
||||||
|
// ID needed). DOCMARK_URL must be set (e.g.
|
||||||
|
// http://docmark.docmark.svc.cluster.local:3001/mcp); DOCMARK_BEARER_TOKEN
|
||||||
|
// is docmark's static bearer (network is docmark's primary auth boundary,
|
||||||
|
// this is defense-in-depth — ADR-0013).
|
||||||
|
func extractViaDocmark(path string) (string, error) {
|
||||||
|
url := os.Getenv("DOCMARK_URL")
|
||||||
|
if url == "" {
|
||||||
|
return "", fmt.Errorf("extractViaDocmark: DOCMARK_URL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody docmarkRequest
|
||||||
|
reqBody.JSONRPC = "2.0"
|
||||||
|
reqBody.ID = 1
|
||||||
|
reqBody.Method = "tools/call"
|
||||||
|
reqBody.Params.Name = "convert_to_markdown"
|
||||||
|
reqBody.Params.Arguments.ContentBase64 = base64.StdEncoding.EncodeToString(raw)
|
||||||
|
reqBody.Params.Arguments.Filename = fileBase(path)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal docmark request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build docmark request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
if tok := os.Getenv("DOCMARK_BEARER_TOKEN"); tok != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call docmark: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read docmark response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("docmark: HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody := body
|
||||||
|
if data := sseDataPayload(body); data != nil {
|
||||||
|
jsonBody = data
|
||||||
|
}
|
||||||
|
|
||||||
|
var out docmarkResponse
|
||||||
|
if err := json.Unmarshal(jsonBody, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("decode docmark response: %w", err)
|
||||||
|
}
|
||||||
|
if out.Error != nil {
|
||||||
|
return "", fmt.Errorf("docmark: %s", out.Error.Message)
|
||||||
|
}
|
||||||
|
if out.Result == nil || len(out.Result.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("docmark: empty response")
|
||||||
|
}
|
||||||
|
text := out.Result.Content[0].Text
|
||||||
|
if out.Result.IsError {
|
||||||
|
return "", fmt.Errorf("docmark: %s", text)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseDataPayload extracts the JSON payload from an SSE-framed response body
|
||||||
|
// ("event: message\r\ndata: {...}\r\n\r\n"). docmark's Streamable-HTTP
|
||||||
|
// transport frames every response this way (Content-Type: text/event-stream)
|
||||||
|
// regardless of stateless_http — that flag removes the session/initialize
|
||||||
|
// requirement, not the SSE wire framing. Returns nil if body isn't SSE-framed
|
||||||
|
// (e.g. a plain-JSON response, kept as a fallback for forward-compatibility).
|
||||||
|
func sseDataPayload(body []byte) []byte {
|
||||||
|
const prefix = "data: "
|
||||||
|
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||||
|
line = bytes.TrimRight(line, "\r")
|
||||||
|
if bytes.HasPrefix(line, []byte(prefix)) {
|
||||||
|
return bytes.TrimPrefix(line, []byte(prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileBase returns the final path segment (like filepath.Base, kept local to
|
||||||
|
// avoid importing path/filepath just for this one call).
|
||||||
|
func fileBase(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
return path[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// ingestion/internal/extract/docmark_test.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpToolResult mirrors the shape of docmark's JSON-RPC tools/call response.
|
||||||
|
type mcpToolResult struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMCPResponse mirrors docmark's REAL response framing (empirically
|
||||||
|
// confirmed against the live server): Content-Type: text/event-stream,
|
||||||
|
// body is SSE-framed ("event: message\r\ndata: {...}\r\n\r\n"), not bare
|
||||||
|
// JSON -- inherent to MCP Streamable-HTTP, independent of stateless_http.
|
||||||
|
func writeMCPResponse(w http.ResponseWriter, body mcpToolResult) {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("event: message\r\ndata: "))
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
_, _ = w.Write([]byte("\r\n\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_Success(t *testing.T) {
|
||||||
|
var gotAuth, gotAccept string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotBody)
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "# Converted\n\nhello"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "test-token-123")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("fake docx bytes"), 0o644))
|
||||||
|
|
||||||
|
got, err := extractViaDocmark(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# Converted\n\nhello", got)
|
||||||
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||||
|
assert.Contains(t, gotAccept, "application/json")
|
||||||
|
params, _ := gotBody["params"].(map[string]any)
|
||||||
|
require.NotNil(t, params)
|
||||||
|
assert.Equal(t, "convert_to_markdown", params["name"])
|
||||||
|
args, _ := params["arguments"].(map[string]any)
|
||||||
|
require.NotNil(t, args)
|
||||||
|
assert.Equal(t, "doc.docx", args["filename"])
|
||||||
|
assert.NotEmpty(t, args["content_base64"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_NotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "DOCMARK_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_ToolErrorSurfacesMessage(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "unsupported format for 'doc.docx'"}},
|
||||||
|
IsError: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_HTTPErrorSurfaces(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "wrong")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "401")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_RoutesDocxXlsxPptxImagesToDocmark(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "converted"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
for _, ext := range []string{".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg"} {
|
||||||
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "f"+ext)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "converted", got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@ package extract
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Text reads the file at path and returns its plain-text content.
|
// Text reads the file at path and returns its plain-text content.
|
||||||
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext),
|
||||||
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg (via docmark, ADR-0013 -- requires
|
||||||
|
// DOCMARK_URL to be set; see docmark.go).
|
||||||
func Text(path string) (string, error) {
|
func Text(path string) (string, error) {
|
||||||
ext := strings.ToLower(fileExt(path))
|
ext := strings.ToLower(fileExt(path))
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -20,6 +24,8 @@ func Text(path string) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
case ".pdf":
|
case ".pdf":
|
||||||
return extractPDF(path)
|
return extractPDF(path)
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return extractViaDocmark(path)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
}
|
}
|
||||||
@@ -37,3 +43,70 @@ func fileExt(path string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TextIn is Text for a file read through an os.Root (hyperguild#153): the
|
||||||
|
// read cannot leave the root, so a path checked earlier cannot be swapped
|
||||||
|
// for a symlink pointing elsewhere before it is read. Formats that need an
|
||||||
|
// external tool (pdf, docmark) are copied to a temp file from the handle
|
||||||
|
// opened through the root, then extracted from the copy.
|
||||||
|
func TextIn(r *os.Root, rel string) (string, error) {
|
||||||
|
fi, err := r.Lstat(rel)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stat %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return "", fmt.Errorf("%s: not a regular file", rel)
|
||||||
|
}
|
||||||
|
f, _, err := OpenRegular(r, rel)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
ext := strings.ToLower(fileExt(rel))
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt":
|
||||||
|
b, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
tmp, err := os.CreateTemp("", "ingest-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("temp copy: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||||
|
if _, err := io.Copy(tmp, f); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
return Text(tmp.Name())
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRegular opens rel inside r and returns it only if the OPENED handle is
|
||||||
|
// a regular file. The open is non-blocking, so a FIFO swapped in after an
|
||||||
|
// Lstat (PR #156 review: it hung open(2) forever) is refused promptly rather
|
||||||
|
// than stalling the reader. Callers keep their Lstat check, which refuses
|
||||||
|
// symlinks outright; this closes the race between that check and the open.
|
||||||
|
func OpenRegular(r *os.Root, rel string) (*os.File, os.FileInfo, error) {
|
||||||
|
f, err := r.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, nil, fmt.Errorf("%s: not a regular file", rel)
|
||||||
|
}
|
||||||
|
return f, fi, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#153 item 3: ingest checked a path, then extract.Text reopened it
|
||||||
|
// by name, so a file swapped for a symlink in between was followed out of
|
||||||
|
// raw/. TextIn reads through an os.Root, which refuses to leave the root.
|
||||||
|
func TestTextInReadsInsideTheRootAndRefusesEscapes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "ok.md"), []byte("inside"), 0o644))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("SENTINEL-153-TEXTIN"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(root, "leak.md")))
|
||||||
|
|
||||||
|
r, err := os.OpenRoot(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = r.Close() }()
|
||||||
|
|
||||||
|
got, err := TextIn(r, "ok.md")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "inside", got)
|
||||||
|
|
||||||
|
got, err = TextIn(r, "leak.md")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.NotContains(t, got, "SENTINEL-153-TEXTIN")
|
||||||
|
|
||||||
|
_, err = TextIn(r, "../"+filepath.Base(outside))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR #156 review: a regular file swapped for a FIFO between Lstat and Open
|
||||||
|
// blocked open(2) forever. OpenRegular opens non-blocking and checks the type
|
||||||
|
// on the opened handle, so a FIFO is refused promptly instead of hanging.
|
||||||
|
func TestOpenRegularRefusesAFIFOWithoutBlocking(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe.md"), 0o644))
|
||||||
|
r, err := os.OpenRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = r.Close() }()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
f, _, err := OpenRegular(r, "pipe.md")
|
||||||
|
if f != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.Error(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("OpenRegular blocked on a FIFO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Package frontmatter lints and repairs the YAML frontmatter block of a
|
||||||
|
// markdown note.
|
||||||
|
//
|
||||||
|
// Notes written directly — hand-authored, or promoted from brain/raw/ — do not
|
||||||
|
// pass through pipeline.BuildPages, so nothing quotes their scalars. A title
|
||||||
|
// containing an unescaped ": " then produces a block that yaml.Unmarshal
|
||||||
|
// rejects with "mapping values are not allowed here", and every consumer that
|
||||||
|
// reads frontmatter silently skips the note.
|
||||||
|
//
|
||||||
|
// Fix is deliberately conservative: it only adds quoting, never reorders or
|
||||||
|
// rewrites, and it refuses to return a document that still does not parse.
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fence = "---"
|
||||||
|
|
||||||
|
// Lint reports whether doc's frontmatter block parses as YAML.
|
||||||
|
//
|
||||||
|
// A document with no frontmatter block, or with an unterminated one, is not an
|
||||||
|
// error: there is nothing claiming to be YAML.
|
||||||
|
func Lint(doc []byte) error {
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(b, &v); err != nil {
|
||||||
|
return fmt.Errorf("frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix quotes unquoted scalars whose value breaks YAML parsing and returns the
|
||||||
|
// repaired document.
|
||||||
|
//
|
||||||
|
// It returns (doc, false, nil) when the document already parses, and
|
||||||
|
// (doc, false, err) when quoting cannot rescue it — an unfixable document is
|
||||||
|
// always returned byte-identical, never half-repaired.
|
||||||
|
func Fix(doc []byte) ([]byte, bool, error) {
|
||||||
|
if err := Lint(doc); err == nil {
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
// Lint only fails when there is a block, so this is unreachable in
|
||||||
|
// practice; return the original rather than guess.
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := quoteScalars(b)
|
||||||
|
if bytes.Equal(fixed, b) {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(fixed, &v); err != nil {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rest, _ := Split(doc)
|
||||||
|
out := []byte(fence + "\n")
|
||||||
|
out = append(out, fixed...)
|
||||||
|
out = append(out, []byte(fence)...)
|
||||||
|
out = append(out, rest...)
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingLine matches "key: value" at any indentation, capturing the prefix so
|
||||||
|
// it can be re-emitted untouched.
|
||||||
|
//
|
||||||
|
// Sequence items ("- value") are deliberately not handled: "- foo: bar" is
|
||||||
|
// valid YAML, so a document containing one does not reach Fix in the first
|
||||||
|
// place. See the Lint table for why that gap is left open.
|
||||||
|
var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`)
|
||||||
|
|
||||||
|
// quoteScalars single-quotes every unquoted scalar value in the block that
|
||||||
|
// contains a colon. Values that are already quoted, or that carry YAML syntax
|
||||||
|
// this package has no business rewriting, are left alone.
|
||||||
|
func quoteScalars(block []byte) []byte {
|
||||||
|
lines := strings.Split(string(block), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := mappingLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix, value := m[1], m[2]
|
||||||
|
if !needsQuoting(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsQuoting reports whether a scalar value has to be quoted to load as the
|
||||||
|
// string it plainly is.
|
||||||
|
//
|
||||||
|
// The test is YAML's own parser rather than a pattern: ask whether the value
|
||||||
|
// stands alone as the right-hand side of a mapping. Heuristics get this wrong
|
||||||
|
// in both directions — `title: "Foo" — bar` opens with a quote but is not
|
||||||
|
// quoted, and a value may contain a colon inside quotes and be perfectly fine.
|
||||||
|
//
|
||||||
|
// Anything that already parses is left alone, whatever it parses into — a flow
|
||||||
|
// collection, a number, a bool. It is not broken, and quoting would change its
|
||||||
|
// type.
|
||||||
|
func needsQuoting(value string) bool {
|
||||||
|
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
|
||||||
|
return false // block scalar; its content is not parsed as YAML
|
||||||
|
}
|
||||||
|
var probe map[string]any
|
||||||
|
return yaml.Unmarshal([]byte("v: "+value+"\n"), &probe) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
|
func block(doc []byte) ([]byte, bool) {
|
||||||
|
b, _, ok := Split(doc)
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split returns doc's frontmatter block and everything from the closing
|
||||||
|
// fence onward (the closing fence itself excluded, its trailing newline
|
||||||
|
// included), or ok=false if doc has no properly-fenced frontmatter.
|
||||||
|
//
|
||||||
|
// The canonical fence detector for this codebase (hyperguild#138): three
|
||||||
|
// independent reimplementations of this same logic each grew their own gap
|
||||||
|
// through separate bug-fixing before being consolidated here —
|
||||||
|
// internal/mcp's needed a CRLF/BOM guard added after both silently defeated
|
||||||
|
// its own HasPrefix check, and internal/api's closing-fence match had no
|
||||||
|
// trailing-newline requirement, so a document ending in "\n---extra" with no
|
||||||
|
// real newline after the fence was treated as validly closed. Both classes
|
||||||
|
// are closed here, once, for every caller.
|
||||||
|
func Split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
|
s := strings.TrimPrefix(string(doc), "\xef\xbb\xbf")
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
body := s[len(fence)+1:]
|
||||||
|
|
||||||
|
idx := strings.Index(body, "\n"+fence)
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
// What follows the closing fence must be either a newline (a real body
|
||||||
|
// follows) or nothing at all (the fence is the literal end of the
|
||||||
|
// document — no body, not a bypass attempt). Anything else, e.g.
|
||||||
|
// "\n---extra", is not a valid close: an earlier version of this check
|
||||||
|
// accepted it, which is exactly the looser match hyperguild#138 closes.
|
||||||
|
after := idx + 1 + len(fence)
|
||||||
|
switch {
|
||||||
|
case after == len(body):
|
||||||
|
return []byte(body[:idx+1]), nil, true
|
||||||
|
case body[after] == '\n':
|
||||||
|
return []byte(body[:idx+1]), []byte(body[after:]), true
|
||||||
|
default:
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// ingestion/internal/frontmatter/frontmatter_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no frontmatter block is not an error",
|
||||||
|
doc: "# Just a heading\n\nSome body text.\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted title parses",
|
||||||
|
doc: "---\ntitle: 'Headless dispatch runs: Bash is denied'\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain title parses",
|
||||||
|
doc: "---\ntitle: A simple title\nwing: homelab\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted colon in title does not parse",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Known gap, deliberately not fixed here: this parses, but the
|
||||||
|
// item becomes map[string]any{"Headless dispatch": "denied"}
|
||||||
|
// rather than a string. It is a silent type corruption, not a
|
||||||
|
// parse failure, so repairing it would rewrite documents that
|
||||||
|
// currently load — out of scope for a lint about broken YAML.
|
||||||
|
name: "unquoted colon in a list item parses as a mapping, not an error",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated frontmatter is not treated as frontmatter",
|
||||||
|
doc: "---\ntitle: no closing fence\n\nbody\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := Lint([]byte(tt.doc))
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantChanged bool
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quotes an unquoted title containing a colon",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Headless dispatch runs: Bash is denied — use gitea MCP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Parses already (see the Lint table), so Fix must leave it alone
|
||||||
|
// rather than rewrite a document that loads.
|
||||||
|
name: "leaves a colon-bearing list item alone because it already parses",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escapes an embedded single quote when quoting",
|
||||||
|
doc: "---\ntitle: Don't do this: it breaks\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Don't do this: it breaks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The value opens with a double quote and then keeps going after
|
||||||
|
// the closing one, so YAML reads it as a quoted scalar followed by
|
||||||
|
// junk. Treating "starts with a quote" as "already quoted" leaves
|
||||||
|
// this broken forever.
|
||||||
|
name: "quotes a value that only looks quoted",
|
||||||
|
doc: "---\ntitle: \"Evaluation Driven Generation\" — a TDD-style scaffold\ntags: [a, b]\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "\"Evaluation Driven Generation\" — a TDD-style scaffold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves an already-valid document untouched",
|
||||||
|
doc: "---\ntitle: 'Already quoted: fine'\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Already quoted: fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves a document with no frontmatter untouched",
|
||||||
|
doc: "# Heading\n\nbody: with a colon\n",
|
||||||
|
wantChanged: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, changed, err := Fix([]byte(tt.doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantChanged, changed)
|
||||||
|
|
||||||
|
if !tt.wantChanged {
|
||||||
|
assert.Equal(t, tt.doc, string(got), "unchanged docs must be returned byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever came back must parse — that is the whole point.
|
||||||
|
require.NoError(t, Lint(got), "Fix must not return a document that still fails to parse")
|
||||||
|
|
||||||
|
if tt.wantKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(block, &m))
|
||||||
|
assert.Equal(t, tt.wantValue, m[tt.wantKey], "the value must survive quoting unchanged")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_LeavesStructuredValuesStructured(t *testing.T) {
|
||||||
|
// Only the broken scalar may be touched. A flow sequence on a neighbouring
|
||||||
|
// line still has to load as a sequence afterwards, not as a string.
|
||||||
|
doc := "---\ntitle: Broken: here\ntags: [prompting, llm]\ncount: 3\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
b, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(b, &m))
|
||||||
|
|
||||||
|
assert.Equal(t, "Broken: here", m["title"])
|
||||||
|
assert.Equal(t, []any{"prompting", "llm"}, m["tags"])
|
||||||
|
assert.Equal(t, 3, m["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_PreservesBodyExactly(t *testing.T) {
|
||||||
|
body := "\nThe body has: colons, 'quotes', and\n---\na horizontal rule.\n"
|
||||||
|
doc := "---\ntitle: Broken: title\n---" + body
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
|
_, rest, ok := Split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
_, gotRest, ok := Split(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
||||||
|
// A frontmatter block that quoting cannot rescue: the structure itself is
|
||||||
|
// broken, not a scalar. Fix must return the original and an error rather
|
||||||
|
// than a half-repaired document.
|
||||||
|
doc := "---\ntitle: fine\n\tbad: tab-indented mapping\n - stray list item\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, changed)
|
||||||
|
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplit_NormalizesCRLFBeforeFenceDetection is hyperguild#138: a note
|
||||||
|
// whose fence lines use CRLF ("---\r\n") failed strings.HasPrefix(doc,
|
||||||
|
// "---\n") on the precondition read in internal/mcp's independent
|
||||||
|
// reimplementation -- encoding alone, no adversarial input, silently
|
||||||
|
// defeated fence detection there before this consolidation. Split must
|
||||||
|
// never repeat that.
|
||||||
|
func TestSplit_NormalizesCRLFBeforeFenceDetection(t *testing.T) {
|
||||||
|
doc := "---\r\ntitle: 'n'\r\nhalf_life: durable\r\n---\r\n\r\nbody\r\n"
|
||||||
|
block, rest, ok := Split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, string(block), "half_life: durable")
|
||||||
|
assert.Contains(t, string(rest), "body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplit_StripsALeadingUTF8BOM is the same mechanism one layer earlier:
|
||||||
|
// a leading BOM (Notepad's default "UTF-8" save, PowerShell's pre-v6
|
||||||
|
// Out-File/Set-Content) defeats the same HasPrefix check before CRLF
|
||||||
|
// normalization even applies.
|
||||||
|
func TestSplit_StripsALeadingUTF8BOM(t *testing.T) {
|
||||||
|
doc := "\xef\xbb\xbf---\ntitle: 'n'\n---\n\nbody\n"
|
||||||
|
block, rest, ok := Split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, string(block), "title: 'n'")
|
||||||
|
assert.Contains(t, string(rest), "body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplit_RequiresANewlineAfterTheClosingFence closes the looser match
|
||||||
|
// internal/api's independent reimplementation carried (strings.Index(body,
|
||||||
|
// "\n"+fence) with no trailing-newline requirement): a document ending in
|
||||||
|
// "\n---extra" with no newline separating the fence from what follows it is
|
||||||
|
// not a valid closing fence and must not be treated as one.
|
||||||
|
func TestSplit_RequiresANewlineAfterTheClosingFence(t *testing.T) {
|
||||||
|
doc := "---\ntitle: 'n'\n---extra-not-a-real-close"
|
||||||
|
_, _, ok := Split([]byte(doc))
|
||||||
|
assert.False(t, ok, "a fence line not followed by a newline is not a valid closing fence")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument is a
|
||||||
|
// regression pin found in review of hyperguild#138: the fix for the
|
||||||
|
// "\n---extra" bypass above must not also reject the legitimate case of a
|
||||||
|
// closing fence with nothing after it at all (no trailing newline because
|
||||||
|
// there's no body, not because a bypass is being attempted) -- this
|
||||||
|
// package's own private split() accepted that shape before the
|
||||||
|
// consolidation, and cmd/frontmatterlint's Lint/Fix relied on it.
|
||||||
|
func TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument(t *testing.T) {
|
||||||
|
doc := "---\ntitle: 'n'\n---"
|
||||||
|
block, rest, ok := Split([]byte(doc))
|
||||||
|
require.True(t, ok, "a closing fence at literal end-of-file, with nothing after it, is valid -- there is no body to require a newline before")
|
||||||
|
assert.Contains(t, string(block), "title: 'n'")
|
||||||
|
assert.Equal(t, "", string(rest))
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one markdown file whose frontmatter does not parse.
|
||||||
|
type Finding struct {
|
||||||
|
Path string // relative to the scanned root
|
||||||
|
Err error // why it does not parse, or why the repair was refused
|
||||||
|
Repaired bool // true only when fix was requested and the file was rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan walks root for *.md files whose frontmatter fails to parse.
|
||||||
|
//
|
||||||
|
// With fix set, each finding that quoting can rescue is rewritten in place;
|
||||||
|
// anything quoting cannot rescue is reported and left byte-identical. A file
|
||||||
|
// that cannot be read or decoded is reported as a finding rather than aborting
|
||||||
|
// the walk — one poisoned note must not hide the other findings.
|
||||||
|
func Scan(root string, fix bool) ([]Finding, error) {
|
||||||
|
var found []Finding
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, relErr := filepath.Rel(root, path)
|
||||||
|
if relErr != nil {
|
||||||
|
rel = path
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: readErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !utf8.Valid(doc) {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fmt.Errorf("not valid UTF-8")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lintErr := Lint(doc); lintErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fix {
|
||||||
|
found = append(found, Finding{Path: rel, Err: Lint(doc)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired, changed, fixErr := Fix(doc)
|
||||||
|
if fixErr != nil || !changed {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fixErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeErr := writeFilePreservingMode(path, repaired); writeErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: writeErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = append(found, Finding{Path: rel, Repaired: true})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFilePreservingMode(path string, content []byte) error {
|
||||||
|
mode := fs.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, content, mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
brokenDoc = "---\ntitle: Broken: title here\nwing: homelab\n---\n\nbody\n"
|
||||||
|
validDoc = "---\ntitle: 'Fine: quoted'\n---\n\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScan_ReportsOnlyBrokenFiles(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
"wiki/b/plain.md": "# no frontmatter\n\nbody: with colon\n",
|
||||||
|
"wiki/a/notes.txt": brokenDoc, // not markdown — must be ignored
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/a/broken.md", found[0].Path)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
// Dry run must not touch the file.
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, brokenDoc, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_FixWritesRepairedFilesAndLeavesTheRestAlone(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.True(t, found[0].Repaired)
|
||||||
|
assert.NoError(t, found[0].Err)
|
||||||
|
|
||||||
|
repaired, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, Lint(repaired), "the repaired file must parse")
|
||||||
|
assert.Contains(t, string(repaired), "title: 'Broken: title here'")
|
||||||
|
assert.Contains(t, string(repaired), "wing: homelab", "untouched keys must survive")
|
||||||
|
|
||||||
|
untouched, err := os.ReadFile(filepath.Join(root, "wiki/a/valid.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, validDoc, string(untouched))
|
||||||
|
|
||||||
|
// Second pass finds nothing — the repair is idempotent and complete.
|
||||||
|
again, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUnfixableWithoutWriting(t *testing.T) {
|
||||||
|
unfixable := "---\ntitle: fine\n\tbad: tab-indented\n - stray\n---\n\nbody\n"
|
||||||
|
root := writeTree(t, map[string]string{"wiki/x.md": unfixable})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unfixable, string(got), "an unfixable file must be left exactly as it was")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUndecodableFilesInsteadOfFailingTheRun(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{"wiki/ok.md": validDoc})
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "wiki/bad-bytes.md"),
|
||||||
|
[]byte{'-', '-', '-', '\n', 0xff, 0xfe, '\n', '-', '-', '-', '\n'}, 0o644))
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err, "one unreadable file must not abort the whole scan")
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/bad-bytes.md", found[0].Path)
|
||||||
|
}
|
||||||
@@ -12,10 +12,14 @@ package graphsync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"io/fs"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
)
|
)
|
||||||
@@ -46,8 +50,25 @@ func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error
|
|||||||
if relPath == "" {
|
if relPath == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
// Callers pass caller-supplied paths (brain_tunnel, brain_update): index
|
||||||
content, err := os.ReadFile(abs)
|
// only a clean path under wiki/ or knowledge/.
|
||||||
|
if path.Clean(relPath) != relPath || !filepath.IsLocal(relPath) ||
|
||||||
|
(!strings.HasPrefix(relPath, "wiki/") && !strings.HasPrefix(relPath, "knowledge/")) {
|
||||||
|
return fmt.Errorf("index %q: not a clean wiki/ or knowledge/ path", relPath)
|
||||||
|
}
|
||||||
|
bfs, err := brainfs.Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open brain: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
|
return indexDoc(ctx, store, bfs, relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexDoc reads relPath through the brain root, regular files only
|
||||||
|
// (hyperguild#157): the brain is a Syncthing folder, and os.ReadFile
|
||||||
|
// followed a synced symlink into the graph and blocked on a FIFO.
|
||||||
|
func indexDoc(ctx context.Context, store Store, bfs *brainfs.Brain, relPath string) error {
|
||||||
|
content, err := bfs.ReadRegular(relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read %q: %w", relPath, err)
|
return fmt.Errorf("read %q: %w", relPath, err)
|
||||||
}
|
}
|
||||||
@@ -77,33 +98,34 @@ func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (in
|
|||||||
if store == nil {
|
if store == nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
roots := []string{"wiki", "knowledge"}
|
bfs, err := brainfs.Open(brainDir)
|
||||||
for _, root := range roots {
|
if err != nil {
|
||||||
base := filepath.Join(brainDir, root)
|
return 0, fmt.Errorf("open brain: %w", err)
|
||||||
if _, err := os.Stat(base); os.IsNotExist(err) {
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
|
for _, root := range []string{"wiki", "knowledge"} {
|
||||||
|
if _, err := bfs.Lstat(root); errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
|
// Any walk error, including a root that is a symlink, aborts.
|
||||||
if walkErr != nil {
|
var walkErr error
|
||||||
return walkErr
|
err := bfs.WalkRegular(root, func(rel string) error {
|
||||||
}
|
if path.Ext(rel) != ".md" {
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if filepath.Ext(path) != ".md" {
|
if err := indexDoc(ctx, store, bfs, rel); err != nil {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, relErr := filepath.Rel(brainDir, path)
|
|
||||||
if relErr != nil {
|
|
||||||
return fmt.Errorf("rel %q: %w", path, relErr)
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if err := IndexDoc(ctx, store, brainDir, rel); err != nil {
|
|
||||||
return fmt.Errorf("index %q: %w", rel, err)
|
return fmt.Errorf("index %q: %w", rel, err)
|
||||||
}
|
}
|
||||||
indexed++
|
indexed++
|
||||||
return nil
|
return nil
|
||||||
|
}, func(rel string, err error) {
|
||||||
|
if walkErr == nil {
|
||||||
|
walkErr = fmt.Errorf("%s: %w", rel, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
if err == nil {
|
||||||
|
err = walkErr
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return indexed, fmt.Errorf("walk %s: %w", root, err)
|
return indexed, fmt.Errorf("walk %s: %w", root, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package graphsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *fakeStore) dump() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return fmt.Sprintf("%+v %+v", f.upserts, f.replaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#157 slice 4: IndexDoc and BackfillFromBrainDir read notes with
|
||||||
|
// os.ReadFile, so a symlinked note put the target's title and wikilinks into
|
||||||
|
// the graph, and a FIFO hung the read.
|
||||||
|
func TestIndexDocRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
facts := filepath.Join(brain, "wiki", "w", "facts")
|
||||||
|
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: zqinside\n---\nbody [[other]]\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
||||||
|
|
||||||
|
store := newFakeStore()
|
||||||
|
require.NoError(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/ok.md"))
|
||||||
|
assert.Contains(t, store.dump(), "zqinside", "positive control: a regular note is indexed")
|
||||||
|
assert.Error(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/leak.md"))
|
||||||
|
// A link to a file elsewhere in the brain: os.Root allows it, only the
|
||||||
|
// regular-file check refuses it.
|
||||||
|
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
||||||
|
assert.Error(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/inlink.md"))
|
||||||
|
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
|
||||||
|
|
||||||
|
p := filepath.Join(facts, "r.md")
|
||||||
|
read := func() string {
|
||||||
|
s := newFakeStore()
|
||||||
|
_ = IndexDoc(context.Background(), s, brain, "wiki/w/facts/r.md")
|
||||||
|
return s.dump()
|
||||||
|
}
|
||||||
|
brainfstest.AssertNoLeak(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, read)
|
||||||
|
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: zqinside\n---\n", inBrain, 1500*time.Millisecond, read)
|
||||||
|
brainfstest.AssertNoHang(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfillSkipsSymlinkedNotesAndAbortsOnASymlinkedRoot(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
facts := filepath.Join(brain, "wiki", "w", "facts")
|
||||||
|
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: zqinside\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "r.md"), []byte("---\ntitle: zqrawonly\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "r.md"), filepath.Join(facts, "inlink.md")))
|
||||||
|
require.NoError(t, os.Symlink(".", filepath.Join(brain, "knowledge")))
|
||||||
|
|
||||||
|
store := newFakeStore()
|
||||||
|
_, err := BackfillFromBrainDir(context.Background(), store, brain)
|
||||||
|
assert.Error(t, err, "a symlinked root aborts the backfill")
|
||||||
|
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
|
||||||
|
assert.NotContains(t, store.dump(), "zqrawonly")
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(brain, "knowledge")))
|
||||||
|
store = newFakeStore()
|
||||||
|
n, err := BackfillFromBrainDir(context.Background(), store, brain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, n, "only the regular note is indexed")
|
||||||
|
assert.Contains(t, store.dump(), "zqinside")
|
||||||
|
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexDoc gets caller-supplied paths (brain_tunnel, brain_update): only a
|
||||||
|
// clean path under wiki/ or knowledge/ is indexed.
|
||||||
|
func TestIndexDocRefusesPathsOutsideWikiAndKnowledgeAndUncleanPaths(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "secret.md"))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
|
||||||
|
for _, p := range []string{"raw/secret.md", "wiki/w/../../raw/secret.md", "./raw/secret.md", "/raw/secret.md"} {
|
||||||
|
store := newFakeStore()
|
||||||
|
assert.Error(t, IndexDoc(context.Background(), store, brain, p), p)
|
||||||
|
assert.NotContains(t, store.dump(), brainfstest.Sentinel, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router calls Primary first; on any error falls back to Fallback.
|
// Router calls Primary first; on any error falls back to Fallback, then to
|
||||||
// Fallback may be nil, in which case primary errors are returned directly.
|
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
||||||
|
// the chain returns the errors accumulated so far.
|
||||||
type Router struct {
|
type Router struct {
|
||||||
Primary *Client
|
Primary *Client
|
||||||
Fallback *Client
|
Fallback *Client
|
||||||
|
Tertiary *Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
// Complete implements pipeline.CompleteFunc, routing through Primary, then
|
||||||
|
// Fallback, then Tertiary — trying each configured tier in order and
|
||||||
|
// returning the first success.
|
||||||
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
out, err := r.Primary.Complete(ctx, system, user)
|
out, err := r.Primary.Complete(ctx, system, user)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
|||||||
return "", fmt.Errorf("primary llm: %w", err)
|
return "", fmt.Errorf("primary llm: %w", err)
|
||||||
}
|
}
|
||||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||||
if err2 != nil {
|
if err2 == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if r.Tertiary == nil {
|
||||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||||
}
|
}
|
||||||
|
out, err3 := r.Tertiary.Complete(ctx, system, user)
|
||||||
|
if err3 != nil {
|
||||||
|
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
|
|||||||
_, err := r.Complete(context.Background(), "sys", "user")
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
tertiary := mockServer(t, "from-tertiary")
|
||||||
|
defer tertiary.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-tertiary", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
fallback := mockServer(t, "from-fallback")
|
||||||
|
defer fallback.Close()
|
||||||
|
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("tertiary must not be called when fallback succeeds")
|
||||||
|
}))
|
||||||
|
defer tertiary.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-fallback", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_AllThreeFail(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(fail.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "primary llm")
|
||||||
|
assert.Contains(t, err.Error(), "fallback llm")
|
||||||
|
assert.Contains(t, err.Error(), "tertiary llm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_NilTertiary(t *testing.T) {
|
||||||
|
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
|
||||||
|
// exactly as before — Tertiary is opt-in, not required.
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "tertiary")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logToolCall emits one structured record per tools/call (hyperguild#144,
|
||||||
|
// OWASP MCP08): who called which tool, against which wing/hall, and how it
|
||||||
|
// ended. Brain content is never copied into the record — only its size — so
|
||||||
|
// the audit trail does not become a second, unscrubbed copy of the brain.
|
||||||
|
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
|
||||||
|
origin, principal := provenanceFrom(ctx)
|
||||||
|
attrs := []any{
|
||||||
|
"tool", name,
|
||||||
|
"principal", principal,
|
||||||
|
"origin", origin,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(args, &a) == nil {
|
||||||
|
if a.Wing != "" {
|
||||||
|
attrs = append(attrs, "wing", a.Wing)
|
||||||
|
}
|
||||||
|
if a.Hall != "" {
|
||||||
|
attrs = append(attrs, "hall", a.Hall)
|
||||||
|
}
|
||||||
|
if a.Content != "" {
|
||||||
|
attrs = append(attrs, "content_bytes", len(a.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The outcome is a class, never the error text: handler errors echo
|
||||||
|
// argument values (a rejected half_life, a requested path), which would
|
||||||
|
// make the audit line a copy of what the caller sent (PR #147 review E6).
|
||||||
|
// The caller already received the full error in the tool response.
|
||||||
|
outcome := "ok"
|
||||||
|
if err != nil {
|
||||||
|
outcome = "error"
|
||||||
|
}
|
||||||
|
attrs = append(attrs, "outcome", outcome)
|
||||||
|
slog.InfoContext(ctx, "mcp tool call", attrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureToolCallLog routes slog to a buffer for fn and returns the decoded
|
||||||
|
// "mcp tool call" records.
|
||||||
|
func captureToolCallLog(t *testing.T, fn func()) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
fn()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||||
|
var rec map[string]any
|
||||||
|
if json.Unmarshal([]byte(l), &rec) == nil && rec["msg"] == "mcp tool call" {
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#144: brain_query/brain_read left no record of who read what.
|
||||||
|
func TestToolsCallLogsOneRecordWithToolPrincipalOriginAndScope(t *testing.T) {
|
||||||
|
srv, _, _, _ := govServer(t)
|
||||||
|
recs := captureToolCallLog(t, func() {
|
||||||
|
authCall(t, srv, "Bearer "+capStaticTok, "brain_query",
|
||||||
|
map[string]any{"query": "anything", "wing": "homelab", "hall": "facts"})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
r := recs[0]
|
||||||
|
assert.Equal(t, "brain_query", r["tool"])
|
||||||
|
assert.Equal(t, "local-cli", r["principal"])
|
||||||
|
assert.Equal(t, "static", r["origin"])
|
||||||
|
assert.Equal(t, "homelab", r["wing"])
|
||||||
|
assert.Equal(t, "facts", r["hall"])
|
||||||
|
assert.Equal(t, "ok", r["outcome"])
|
||||||
|
assert.Contains(t, r, "duration_ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsCallWithoutDerivablePrincipalLogsOriginUnknown(t *testing.T) {
|
||||||
|
srv, _, _, _ := govServer(t)
|
||||||
|
recs := captureToolCallLog(t, func() {
|
||||||
|
authCall(t, srv, "", "brain_query", map[string]any{"query": "x"})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "unknown", recs[0]["origin"])
|
||||||
|
assert.Equal(t, "", recs[0]["principal"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsCallLogsErrorOutcome(t *testing.T) {
|
||||||
|
srv, _, _, _ := govServer(t)
|
||||||
|
recs := captureToolCallLog(t, func() {
|
||||||
|
authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "error", recs[0]["outcome"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit line must not become a second copy of brain content.
|
||||||
|
func TestToolsCallLogOmitsContentButRecordsItsSize(t *testing.T) {
|
||||||
|
const sentinel = "SENTINEL-BRAIN-CONTENT-91c2"
|
||||||
|
srv, _, _, _ := govServer(t)
|
||||||
|
recs := captureToolCallLog(t, func() {
|
||||||
|
authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# t\n\n" + sentinel, "wing": "testing", "hall": "facts", "filename": "audit-sentinel",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
raw, _ := json.Marshal(recs[0])
|
||||||
|
assert.NotContains(t, string(raw), sentinel)
|
||||||
|
assert.EqualValues(t, len("# t\n\n"+sentinel), recs[0]["content_bytes"])
|
||||||
|
}
|
||||||
@@ -165,7 +165,7 @@ func TestBrainUpdateReembedsNewContent(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "# Note\n\nthe OLD distinctive payload\n",
|
"content": "# Note\n\nthe OLD distinctive payload\n",
|
||||||
"filename": "n", "wing": "a", "hall": "facts",
|
"filename": "n", "wing": "a", "hall": "facts",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
)
|
)
|
||||||
@@ -52,14 +53,12 @@ func (s *Server) tools() []map[string]any {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_write",
|
"name": "brain_write",
|
||||||
"description": "Write a markdown note to the brain. With wing+hall set, routes to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter; otherwise writes to brain/knowledge/ (legacy).",
|
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
|
||||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
"inputSchema": schema([]string{"content", "wing", "hall"}, map[string]any{
|
||||||
"content": str("markdown content"),
|
"content": str("markdown content"),
|
||||||
"filename": str("optional filename or slug"),
|
"filename": str("optional filename or slug"),
|
||||||
"type": str("optional frontmatter type (legacy)"),
|
"wing": str("topic domain, e.g. jepa-fx"),
|
||||||
"domain": str("optional frontmatter domain (legacy)"),
|
"hall": enum("memory type", halls...),
|
||||||
"wing": str("optional topic domain, e.g. jepa-fx"),
|
|
||||||
"hall": enum("optional memory type (requires wing)", halls...),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,6 +96,16 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_reject",
|
||||||
|
"description": "Reject a brain/raw/ note: stamps rejected_at (+ reject_reason if given) into frontmatter and moves it to brain/raw/rejected/ — never deleted outright, and never re-surfaced by brain_pending. The third option alongside promote/leave-pending; a human decision, never automatic. Returns {path}.",
|
||||||
|
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||||
|
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||||
|
"reason": str("optional; why this isn't worth promoting"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
noteToolDescriptors()[0],
|
||||||
|
noteToolDescriptors()[1],
|
||||||
{
|
{
|
||||||
"name": "brain_tunnel",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
@@ -105,13 +114,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"target": str("path of target note (must be in a different wing)"),
|
"target": str("path of target note (must be in a different wing)"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "brain_index",
|
|
||||||
"description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.",
|
|
||||||
"inputSchema": schema([]string{}, map[string]any{
|
|
||||||
"wing": str("optional wing to index; if absent, rebuilds every wing"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "brain_ingest_raw",
|
"name": "brain_ingest_raw",
|
||||||
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
||||||
@@ -231,12 +233,19 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
type brainWriteArgs struct {
|
type brainWriteArgs struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Domain string `json:"domain,omitempty"`
|
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlinkedNote explains the `unlinked` field to the caller that caused it.
|
||||||
|
// The write landed; what is gone is the [[...]] around a target that names no
|
||||||
|
// brain entry. Said here rather than only in a doc comment because the caller
|
||||||
|
// is a model that will otherwise repeat it on the next write.
|
||||||
|
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
|
||||||
|
"so they were unwrapped to plain text. Link only entries that exist " +
|
||||||
|
"(a filename under knowledge/ or wiki/); write the target entry first if " +
|
||||||
|
"you want the link, and keep skills, repos and agent-local memory files as prose."
|
||||||
|
|
||||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
var a brainWriteArgs
|
var a brainWriteArgs
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
@@ -246,18 +255,24 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
// one implementation (capture uses the same store). The read-after-write
|
// one implementation (capture uses the same store). The read-after-write
|
||||||
// handle {id, path, content_hash} comes back from the store; path is kept
|
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||||
// for backward compatibility.
|
// for backward compatibility.
|
||||||
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
ref, err := s.store.Write(ctx, capture.Note{
|
ref, err := s.store.Write(ctx, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Filename: a.Filename,
|
Filename: a.Filename,
|
||||||
Type: a.Type,
|
Wing: a.Wing,
|
||||||
Domain: a.Domain,
|
Hall: a.Hall,
|
||||||
Wing: a.Wing,
|
Origin: origin,
|
||||||
Hall: a.Hall,
|
WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
||||||
|
if len(ref.Unlinked) > 0 {
|
||||||
|
out["unlinked"] = ref.Unlinked
|
||||||
|
out["unlinked_note"] = unlinkedNote
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainUpdateArgs struct {
|
type brainUpdateArgs struct {
|
||||||
@@ -292,18 +307,26 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
slug = a.Path
|
slug = a.Path
|
||||||
}
|
}
|
||||||
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
ref, err := s.store.Update(ctx, slug, capture.Note{
|
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Wing: a.Wing,
|
Wing: a.Wing,
|
||||||
Hall: a.Hall,
|
Hall: a.Hall,
|
||||||
Reason: a.Reason,
|
Reason: a.Reason,
|
||||||
|
Origin: origin,
|
||||||
|
WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{
|
out := map[string]any{
|
||||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||||
})
|
}
|
||||||
|
if len(ref.Unlinked) > 0 {
|
||||||
|
out["unlinked"] = ref.Unlinked
|
||||||
|
out["unlinked_note"] = unlinkedNote
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainGetArgs struct {
|
type brainGetArgs struct {
|
||||||
@@ -327,6 +350,15 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("id or path is required")
|
return nil, fmt.Errorf("id or path is required")
|
||||||
}
|
}
|
||||||
|
// Containment: the store resolves any brain-relative path, and
|
||||||
|
// parseFrontmatter returns the WHOLE file as body when there is no
|
||||||
|
// frontmatter fence — so without this, brain_get reads verbatim session
|
||||||
|
// transcripts under archive/ and sessions/, which BM25 never indexes and
|
||||||
|
// which nobody therefore thinks of as reachable. Same allowlist as
|
||||||
|
// brain_read (brain#4).
|
||||||
|
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
note, err := s.store.Get(ctx, target)
|
note, err := s.store.Get(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -361,8 +393,10 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||||
|
Origin: origin, WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -371,6 +405,26 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
return json.Marshal(map[string]string{"path": relPath})
|
return json.Marshal(map[string]string{"path": relPath})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type brainRejectArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||||
|
// frontmatter-stamp + move to raw/rejected/). No graph indexing — a rejected
|
||||||
|
// note never entered the wiki, so there's nothing to index.
|
||||||
|
func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainRejectArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]string{"path": relPath})
|
||||||
|
}
|
||||||
|
|
||||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||||
// logs failures but never propagates them — the underlying write/ingest
|
// logs failures but never propagates them — the underlying write/ingest
|
||||||
// has already succeeded and the graph is an augmentation, not a
|
// has already succeeded and the graph is an augmentation, not a
|
||||||
@@ -445,7 +499,7 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
|||||||
if len(a.Pages) == 0 {
|
if len(a.Pages) == 0 {
|
||||||
return nil, fmt.Errorf("pages must be non-empty")
|
return nil, fmt.Errorf("pages must be non-empty")
|
||||||
}
|
}
|
||||||
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
|
result, err := pipeline.RunRaw(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingest: %w", err)
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
}
|
}
|
||||||
@@ -488,7 +542,13 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
text, err := extract.Text(a.Path)
|
// Confined to brain/raw/ with symlinks evaluated (hyperguild#149).
|
||||||
|
resolved, err := api.ResolveIngestPath(s.brainDir, a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Path = resolved
|
||||||
|
text, err := api.ReadIngestFile(s.brainDir, a.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: %w", err)
|
return nil, fmt.Errorf("extract: %w", err)
|
||||||
}
|
}
|
||||||
@@ -544,7 +604,7 @@ func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
|
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
|
||||||
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
|
result, err := pipeline.Run(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingest: %w", err)
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
}
|
}
|
||||||
@@ -563,3 +623,10 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
|||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withCallerStamp attaches the caller's provenance for the ingest pipeline,
|
||||||
|
// which stamps every page it writes or merges into (hyperguild#145).
|
||||||
|
func withCallerStamp(ctx context.Context) context.Context {
|
||||||
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
|
return provenance.WithStamp(ctx, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,25 +51,50 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
assert.Contains(t, text, "tdd.md")
|
assert.Contains(t, text, "tdd.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainWriteCreatesFile(t *testing.T) {
|
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
|
||||||
|
// domain/type were removed from the schema and the handler's arg struct, so
|
||||||
|
// a caller that still sends them (the old MCP surface) gets a normal wing+
|
||||||
|
// hall write with no error and no effect from the stale fields — silently
|
||||||
|
// dropped by json.Unmarshal, not rejected. Only content in its OWN
|
||||||
|
// frontmatter (unrelated to these params) can set type/domain, per
|
||||||
|
// TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking in the
|
||||||
|
// api package.
|
||||||
|
func TestBrainWriteIgnoresLegacyTypeAndDomainParams(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "# Test\n\nbody",
|
"content": "# Test\n\nbody",
|
||||||
"filename": "test.md",
|
"filename": "test",
|
||||||
|
"wing": "homelab",
|
||||||
|
"hall": "facts",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"domain": "personal",
|
"domain": "personal",
|
||||||
})
|
})
|
||||||
require.Nil(t, resp["error"])
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "homelab", "facts", "test.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(got), "type: note")
|
assert.NotContains(t, string(got), "type: note")
|
||||||
assert.Contains(t, string(got), "domain: personal")
|
assert.NotContains(t, string(got), "domain: personal")
|
||||||
assert.Contains(t, string(got), "# Test")
|
assert.Contains(t, string(got), "# Test")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBrainWriteRequiresWingAndHall is the MCP-tool-call counterpart of
|
||||||
|
// api.TestWriteNote_RequiresWingAndHall: the legacy brain/knowledge/
|
||||||
|
// fallback is gone, so a caller omitting either param gets a tool error,
|
||||||
|
// not a silent legacy write.
|
||||||
|
func TestBrainWriteRequiresWingAndHall(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
"filename": "no-destination",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
@@ -208,30 +233,11 @@ func TestBrainTunnelRejectsMissing(t *testing.T) {
|
|||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
// Filename traversal/sanitisation on the wiki route is covered directly
|
||||||
brainDir := t.TempDir()
|
// against brain.NotePath/Sanitise in internal/brain/path_test.go
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
|
||||||
|
// the now-deleted brain/knowledge/ legacy path's own separate filename
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
// validation, which had no equivalent once that path was removed (#79).
|
||||||
"content": "x",
|
|
||||||
"filename": "../escape.md",
|
|
||||||
})
|
|
||||||
require.NotNil(t, resp["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
|
||||||
brainDir := t.TempDir()
|
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
||||||
"content": "x",
|
|
||||||
"filename": "notes..draft.md",
|
|
||||||
})
|
|
||||||
require.Nil(t, resp["error"])
|
|
||||||
|
|
||||||
_, err := os.Stat(filepath.Join(brainDir, "knowledge", "notes..draft.md"))
|
|
||||||
require.NoError(t, err, "filename with embedded .. should be allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainIngestRawDryRun(t *testing.T) {
|
func TestBrainIngestRawDryRun(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
@@ -390,3 +396,38 @@ func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
|||||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainRejectMovesToRejectedSubdir(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-04-23-scrape.md"),
|
||||||
|
[]byte("---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Scrape\n\nbody\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_reject", map[string]any{
|
||||||
|
"filename": "2026-04-23-scrape.md", "reason": "generic scrape, not homelab-relevant",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, "raw/rejected/2026-04-23-scrape.md")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "raw/rejected/2026-04-23-scrape.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "reject_reason: generic scrape, not homelab-relevant")
|
||||||
|
|
||||||
|
_, srcErr := os.Stat(filepath.Join(raw, "2026-04-23-scrape.md"))
|
||||||
|
assert.True(t, os.IsNotExist(srcErr), "source removed from raw/")
|
||||||
|
|
||||||
|
// A rejected note must not still show up as pending.
|
||||||
|
pendingResp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||||
|
pendingText := pendingResp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, pendingText, `"pending":[]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: brain_ingest's `path` read any file the pod could read and
|
||||||
|
// ingested it into the brain, where brain_query reads it back out.
|
||||||
|
func TestBrainIngestRefusesAPathOutsideTheRawInbox(t *testing.T) {
|
||||||
|
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
return `[{"title":"Leak","type":"concept","content":"x"}]`, nil
|
||||||
|
}}
|
||||||
|
srv := mcp.NewServer(newBrainRepo(t), &stub, nil, nil)
|
||||||
|
resp := authCall(t, srv, "", "brain_ingest", map[string]any{"path": "/etc/hostname"})
|
||||||
|
require.NotNil(t, resp["error"], "a path outside brain/raw/ must be refused")
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"], "brain/raw/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingNudgeThreshold is how old a raw/ note has to be before brain_context
|
||||||
|
// mentions the queue. Two weeks: long enough that a note written this sprint
|
||||||
|
// does not nag, short enough that four items cannot sit unnoticed for three
|
||||||
|
// months again (#82).
|
||||||
|
const pendingNudgeThreshold = 14 * 24 * time.Hour
|
||||||
|
|
||||||
|
// PendingNudge is the in-session reminder that the promote/reject queue has
|
||||||
|
// items waiting. It is advisory: a count, an age, and how to look.
|
||||||
|
type PendingNudge struct {
|
||||||
|
Pending int `json:"pending"` // everything in the queue
|
||||||
|
Aging int `json:"aging"` // those past the threshold
|
||||||
|
Oldest string `json:"oldest"` // filename of the oldest pending note
|
||||||
|
OldestDays int `json:"oldest_days"` // its age, in whole days
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingNudge summarises the raw/ review queue, or returns nil when there is
|
||||||
|
// nothing worth saying.
|
||||||
|
//
|
||||||
|
// The trigger is the missing half of the queue (#82): brain_pending has always
|
||||||
|
// existed, and four real items still sat unreviewed from 2026-04-23 to
|
||||||
|
// 2026-07-15 because nothing ever prompted anyone to call it. The system
|
||||||
|
// should be what remembers the queue exists.
|
||||||
|
//
|
||||||
|
// Threshold-gated on purpose. A notice on every context call is one people
|
||||||
|
// learn to skip, which reproduces the original failure with extra steps.
|
||||||
|
//
|
||||||
|
// It never promotes, rejects, or reorders anything. Age is a reason to ask a
|
||||||
|
// human, not a verdict — an old note may be exactly right to promote once
|
||||||
|
// someone finally looks at it.
|
||||||
|
func pendingNudge(brainDir string, now time.Time, threshold time.Duration) *PendingNudge {
|
||||||
|
pending, err := api.ListPending(brainDir)
|
||||||
|
if err != nil || len(pending) == 0 {
|
||||||
|
// brain_context is a hot path; a broken queue degrades to silence
|
||||||
|
// rather than failing the call that asked for project context.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var aging int
|
||||||
|
var oldestName string
|
||||||
|
var oldestAge time.Duration
|
||||||
|
|
||||||
|
for _, p := range pending {
|
||||||
|
created, perr := time.Parse(time.RFC3339, p.CreatedAt)
|
||||||
|
if perr != nil {
|
||||||
|
continue // an unparseable timestamp is not evidence of age
|
||||||
|
}
|
||||||
|
age := now.Sub(created)
|
||||||
|
if age >= threshold {
|
||||||
|
aging++
|
||||||
|
}
|
||||||
|
if age > oldestAge {
|
||||||
|
oldestAge, oldestName = age, p.Filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if aging == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PendingNudge{
|
||||||
|
Pending: len(pending),
|
||||||
|
Aging: aging,
|
||||||
|
Oldest: oldestName,
|
||||||
|
OldestDays: int(oldestAge.Hours() / 24),
|
||||||
|
Hint: "call brain_pending to review; nothing is promoted or rejected automatically",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writePending drops a raw/ note whose created_at is age old.
|
||||||
|
func writePending(t *testing.T, brainDir, name string, age time.Duration, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
body := "---\ncreated_at: " + now.Add(-age).UTC().Format(time.RFC3339) + "\n---\n\nnote body\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, name), []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SilentWhenNothingIsPending(t *testing.T) {
|
||||||
|
// No queue, no nudge. A notice that fires every session is one people
|
||||||
|
// learn to skip, which is how the queue got ignored for three months in
|
||||||
|
// the first place (#82).
|
||||||
|
got := pendingNudge(t.TempDir(), time.Now(), 14*24*time.Hour)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SilentWhileEverythingIsBelowTheThreshold(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "fresh.md", 2*24*time.Hour, now)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
assert.Nil(t, got, "a two-day-old note is not aging")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_ReportsCountAndOldestAgeOnceSomethingAges(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "old.md", 100*24*time.Hour, now)
|
||||||
|
writePending(t, dir, "older.md", 120*24*time.Hour, now)
|
||||||
|
writePending(t, dir, "fresh.md", 1*24*time.Hour, now)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
|
||||||
|
// All three are pending; the threshold decides whether to speak, not
|
||||||
|
// what to count. Someone opening the queue sees the whole queue.
|
||||||
|
assert.Equal(t, 3, got.Pending)
|
||||||
|
assert.Equal(t, 2, got.Aging)
|
||||||
|
assert.Equal(t, 120, got.OldestDays)
|
||||||
|
assert.Equal(t, "older.md", got.Oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_NeverDecidesAnything(t *testing.T) {
|
||||||
|
// Aging is a trigger to ask a human, never a verdict. The nudge must
|
||||||
|
// carry no action and must leave the queue exactly as it found it.
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "ancient.md", 365*24*time.Hour, now)
|
||||||
|
|
||||||
|
before, err := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
|
||||||
|
after, err := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, after, len(before), "the nudge must not promote or reject anything")
|
||||||
|
assert.Contains(t, got.Hint, "brain_pending", "the nudge should say how to look, not act")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SurvivesAnUnreadableQueue(t *testing.T) {
|
||||||
|
// brain_context is a hot path; a broken raw/ dir must degrade to silence
|
||||||
|
// rather than failing the whole context call.
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), []byte("not a directory"), 0o644))
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
assert.Nil(t, pendingNudge(dir, time.Now(), 14*24*time.Hour))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_CarriesTheNudgeWithoutFailingWhenTheQueueIsEmpty(t *testing.T) {
|
||||||
|
// The wiring, not the summary: a nudge that never reaches a caller is the
|
||||||
|
// same as no nudge, and brain_context must still work with no queue.
|
||||||
|
dir := t.TempDir()
|
||||||
|
srv := NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
raw, err := srv.brainContext(context.Background(),
|
||||||
|
json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var empty map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &empty))
|
||||||
|
assert.NotContains(t, empty, "pending_review", "silence when nothing is aging")
|
||||||
|
|
||||||
|
writePending(t, dir, "ancient.md", 200*24*time.Hour, time.Now())
|
||||||
|
|
||||||
|
raw, err = srv.brainContext(context.Background(),
|
||||||
|
json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var withQueue map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &withQueue))
|
||||||
|
nudge, ok := withQueue["pending_review"].(map[string]any)
|
||||||
|
require.True(t, ok, "the nudge must reach the caller: %v", withQueue)
|
||||||
|
assert.Equal(t, float64(1), nudge["aging"])
|
||||||
|
assert.Equal(t, "ancient.md", nudge["oldest"])
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Escapes found by the PR #147 gatekeeper review. Each test drives the real
|
||||||
|
// mcp.Server as a JWT principal "attacker" and asserts the provenance
|
||||||
|
// invariant on the file every reader sees.
|
||||||
|
|
||||||
|
const jwtAuthz = "Bearer jwt-token"
|
||||||
|
|
||||||
|
func jwtServer(t *testing.T) (*mcp.Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
cfg, err := classification.Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
srv.WithCapture(svc, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli",
|
||||||
|
capturehttp.NewOriginResolver(nil))
|
||||||
|
return srv, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
var provenanceLine = regexp.MustCompile(`(?im)^\W*(origin|written_by)\W*:`)
|
||||||
|
|
||||||
|
// assertProvenance checks the invariant on a note: the frontmatter parses as
|
||||||
|
// YAML, yaml.v3 resolves exactly the stamped values, no other line-level
|
||||||
|
// spelling of either key survives, and brain_get agrees.
|
||||||
|
func assertProvenance(t *testing.T, srv *mcp.Server, dir, rel, wantOrigin, wantBy string) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
fm := frontmatterOf(string(b))
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(fm), &m), "frontmatter must parse as YAML:\n%s", fm)
|
||||||
|
assert.Equal(t, wantOrigin, m["origin"], "yaml.v3 origin\n%s", fm)
|
||||||
|
if wantBy == "" {
|
||||||
|
assert.NotContains(t, m, "written_by", "yaml.v3 written_by\n%s", fm)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, wantBy, m["written_by"], "yaml.v3 written_by\n%s", fm)
|
||||||
|
}
|
||||||
|
wantLines := 1
|
||||||
|
if wantBy != "" {
|
||||||
|
wantLines = 2
|
||||||
|
}
|
||||||
|
assert.Len(t, provenanceLine.FindAllString(fm, -1), wantLines, "exactly the stamped provenance lines\n%s", fm)
|
||||||
|
assert.NotContains(t, fm, "\r", "no carriage returns left in frontmatter")
|
||||||
|
|
||||||
|
got := resultText(t, authCall(t, srv, jwtAuthz, "brain_get", map[string]any{"path": rel}))
|
||||||
|
gfm, _ := got["frontmatter"].(map[string]any)
|
||||||
|
for k, v := range gfm {
|
||||||
|
lk := strings.ToLower(strings.Trim(k, `"' ?`))
|
||||||
|
if lk == "origin" {
|
||||||
|
assert.Equal(t, wantOrigin, v, "brain_get key %q", k)
|
||||||
|
}
|
||||||
|
if lk == "written_by" {
|
||||||
|
assert.Equal(t, wantBy, v, "brain_get key %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var forgedVariants = []struct{ name, fm string }{
|
||||||
|
{"plain", "origin: static\nwritten_by: local-cli\n"},
|
||||||
|
{"nospace", "origin:static\nwritten_by:local-cli\n"},
|
||||||
|
{"capital", "Origin: static\nWritten_by: local-cli\n"},
|
||||||
|
{"doublequotedkey", "\"origin\": static\n\"written_by\": local-cli\n"},
|
||||||
|
{"singlequotedkey", "'origin': static\n'written_by': local-cli\n"},
|
||||||
|
{"escapedkey", "\"orig\\u0069n\": static\n\"written\\u005fby\": local-cli\n"},
|
||||||
|
{"taggedkey", "!!str origin: static\n!!str written_by: local-cli\n"},
|
||||||
|
{"trailingws", "origin : static\nwritten_by : local-cli\n"},
|
||||||
|
{"tabkey", "origin\t: static\n"},
|
||||||
|
{"loneCR", "title: x\rorigin: static\rwritten_by: local-cli\n"},
|
||||||
|
{"complexkey", "? origin\n: static\n? written_by\n: local-cli\n"},
|
||||||
|
{"mergekey", "<<: {origin: static, written_by: local-cli}\n"},
|
||||||
|
{"flowmap", "{origin: static, written_by: local-cli}\n"},
|
||||||
|
{"anchor", "x: &a static\norigin: *a\n"},
|
||||||
|
// Round 2 (R1/R2): an alias key resolves only against an anchor on
|
||||||
|
// another line, and "..." ends the YAML document before the stamp.
|
||||||
|
{"aliaskey_docend", "x: &a origin\n*a : static\ny: &b written_by\n*b : local-cli\n...\n"},
|
||||||
|
{"aliaskey", "y: &b written_by\n*b : local-cli\n"},
|
||||||
|
{"docend_only", "x: y\n...\n"},
|
||||||
|
{"docstart_inside", "x: y\n---\norigin: static\n"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
|
||||||
|
for _, v := range forgedVariants {
|
||||||
|
t.Run(v.name, func(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "---\n" + v.fm + "---\n# V\n\nbody", "wing": "testing", "hall": "facts",
|
||||||
|
"filename": "forge-" + v.name,
|
||||||
|
}))
|
||||||
|
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainUpdateAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
|
||||||
|
for _, v := range forgedVariants {
|
||||||
|
t.Run(v.name, func(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "# V\n\nv1", "wing": "testing", "hall": "facts", "filename": "upd-" + v.name,
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
resultText(t, authCall(t, srv, jwtAuthz, "brain_update", map[string]any{
|
||||||
|
"path": path, "content": "---\n" + v.fm + "---\n# V\n\nv2",
|
||||||
|
}))
|
||||||
|
assertProvenance(t, srv, dir, path, "jwt", "attacker")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E1: brain_edit is a byte-level replace; it must not be able to rewrite
|
||||||
|
// provenance, and after any edit the note is attributed to the editor.
|
||||||
|
func TestBrainEditCannotRewriteProvenanceAndRestampsToEditor(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-forge",
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
|
||||||
|
"path": path, "old_str": "origin: jwt\nwritten_by: attacker\n", "new_str": "origin: static\nwritten_by: local-cli\n",
|
||||||
|
})
|
||||||
|
assertProvenance(t, srv, dir, path, "jwt", "attacker")
|
||||||
|
|
||||||
|
// Assembling the value from existing text must not work either.
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "jwt", "new_str": "static"})
|
||||||
|
assertProvenance(t, srv, dir, path, "jwt", "attacker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A static principal's note edited by a JWT session becomes the JWT
|
||||||
|
// session's: the content a reader sees is no longer the static author's.
|
||||||
|
func TestBrainEditByJWTReattributesAStaticNote(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# S\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-reattr",
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
|
||||||
|
"path": path, "old_str": "static body", "new_str": "injected body",
|
||||||
|
}))
|
||||||
|
assertProvenance(t, srv, dir, path, "jwt", "attacker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// E1 via round 2's payload: the alias + document-end trick through brain_edit.
|
||||||
|
func TestBrainEditCannotForgeProvenanceWithAliasKeys(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "---\ntitle: t\n---\n# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-alias",
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
|
||||||
|
"path": path, "old_str": "title: t\n", "new_str": "title: t\nx: &a origin\n*a : static\n...\n",
|
||||||
|
})
|
||||||
|
assertProvenance(t, srv, dir, path, "jwt", "attacker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// E2 + E4: brain_ingest_raw stamps every page it writes, and a newline in a
|
||||||
|
// title cannot start a new frontmatter key — observed through brain_query,
|
||||||
|
// whose reader is line-oriented, not just through yaml.v3 (round 2, R4).
|
||||||
|
func TestBrainIngestRawStampsPagesAndNeutralisesTitleNewlines(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
|
||||||
|
"source": "zqsrc", "pages": []map[string]any{
|
||||||
|
{"title": "Zqplain", "type": "concept", "content": "plain body"},
|
||||||
|
{"title": "Zqinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "inj body"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
pages := out["pages"].([]any)
|
||||||
|
require.NotEmpty(t, pages)
|
||||||
|
for _, p := range pages {
|
||||||
|
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
|
||||||
|
b, _ := os.ReadFile(filepath.Join(dir, p.(string)))
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(frontmatterOf(string(b))), &m))
|
||||||
|
assert.NotContains(t, m, "x", "an injected key must not appear in %s", p)
|
||||||
|
}
|
||||||
|
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "inj body"}))
|
||||||
|
var sawPage bool
|
||||||
|
for _, r := range q["results"].([]any) {
|
||||||
|
hit := r.(map[string]any)
|
||||||
|
// Generated aggregates (wiki/index.md, log.md, wing _index.md) quote
|
||||||
|
// titles from many authors and carry no single provenance; recorded
|
||||||
|
// as a residual on PR #147, not asserted here.
|
||||||
|
if !strings.HasPrefix(hit["path"].(string), "wiki/concepts/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sawPage = true
|
||||||
|
assert.Equal(t, "jwt", hit["origin"], "line reader origin for %v", hit["path"])
|
||||||
|
assert.Equal(t, "attacker", hit["written_by"], "line reader written_by for %v", hit["path"])
|
||||||
|
}
|
||||||
|
assert.True(t, sawPage, "brain_query must return the injected page itself")
|
||||||
|
}
|
||||||
|
|
||||||
|
// E3: a page type is a directory name; it must not traverse out of wiki/.
|
||||||
|
func TestBrainIngestRawRejectsPathTraversingType(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
|
||||||
|
"source": "zqsrc2", "pages": []map[string]any{{"title": "Zqpromo", "type": "../raw", "content": "b"}},
|
||||||
|
})
|
||||||
|
ents, _ := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
assert.Empty(t, ents, "nothing may be written outside wiki/ by a page type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// E3: promotion re-attributes the note to whoever promotes it, regardless of
|
||||||
|
// what the raw note's frontmatter claims.
|
||||||
|
func TestBrainPromoteRestampsToPromoter(t *testing.T) {
|
||||||
|
srv, dir := jwtServer(t)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "planted.md"),
|
||||||
|
[]byte("---\ntitle: Planted\norigin: static\nwritten_by: local-cli\n---\n# Planted\n\nbody\n"), 0o644))
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_promote", map[string]any{
|
||||||
|
"filename": "planted.md", "wing": "testing", "hall": "facts",
|
||||||
|
}))
|
||||||
|
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// E6: an error outcome must not carry argument values into the audit log.
|
||||||
|
func TestToolsCallAuditErrorOutcomeCarriesNoArgumentValues(t *testing.T) {
|
||||||
|
const sentinel = "SENTINEL-ERR-7d1e"
|
||||||
|
srv, _ := jwtServer(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "---\nhalf_life: " + sentinel + "\n---\n# H\n\nbody", "wing": "testing", "hall": "facts",
|
||||||
|
})
|
||||||
|
authCall(t, srv, jwtAuthz, "brain_update", map[string]any{"path": "wiki/testing/facts/" + sentinel + ".md", "content": "x"})
|
||||||
|
var sawError bool
|
||||||
|
for _, l := range strings.Split(buf.String(), "\n") {
|
||||||
|
var rec map[string]any
|
||||||
|
if json.Unmarshal([]byte(l), &rec) != nil || rec["msg"] != "mcp tool call" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec["outcome"] == "error" {
|
||||||
|
sawError = true
|
||||||
|
}
|
||||||
|
assert.NotContains(t, l, sentinel)
|
||||||
|
}
|
||||||
|
assert.True(t, sawError, "the failing calls must still be recorded as errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 3 (B1): brain_edit adding a block scalar containing an indented "---"
|
||||||
|
// must not hide provenance from brain_query's reader.
|
||||||
|
func TestBrainEditBlockScalarFenceDoesNotHideProvenanceFromQuery(t *testing.T) {
|
||||||
|
srv, _ := jwtServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
|
||||||
|
"content": "---\nk: v\n---\n# E\n\nzqrthreeeditdash", "wing": "testing", "hall": "facts", "filename": "edit-dash",
|
||||||
|
}))
|
||||||
|
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
|
||||||
|
"path": out["path"], "old_str": "k: v\n", "new_str": "k: |\n ---\n",
|
||||||
|
}))
|
||||||
|
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "zqrthreeeditdash"}))
|
||||||
|
hit := q["results"].([]any)[0].(map[string]any)
|
||||||
|
assert.Equal(t, "jwt", hit["origin"])
|
||||||
|
assert.Equal(t, "attacker", hit["written_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 3 (B2): when the editor's stamp cannot be verified, the edit is rolled
|
||||||
|
// back byte for byte and the tool says so. A JWT subject with an invalid UTF-8
|
||||||
|
// byte cannot round-trip through YAML, which forces that path.
|
||||||
|
func TestBrainEditRollsBackWhenTheStampCannotBeVerified(t *testing.T) {
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
cfg, err := classification.Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
srv.WithCapture(svc, capFakeValidator{subject: "bad\xff"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
|
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# R\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-rollback",
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
before, err := os.ReadFile(filepath.Join(dir, path))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "static body", "new_str": "injected"})
|
||||||
|
require.NotNil(t, resp["error"], "the edit must be reported as failed")
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"], "rolled back")
|
||||||
|
after, err := os.ReadFile(filepath.Join(dir, path))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, string(before), string(after), "the note must be byte-identical to before the edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP brain_ingest (the LLM path) attributes its pages to the caller.
|
||||||
|
func TestBrainIngestViaMCPStampsCaller(t *testing.T) {
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
cfg, err := classification.Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
|
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
return `[{"title":"Zqllmpage","type":"concept","content":"zqllm body"}]`, nil
|
||||||
|
}}
|
||||||
|
srv := mcp.NewServer(dir, &stub, nil, nil)
|
||||||
|
srv.WithCapture(svc, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest", map[string]any{"content": "article text", "source": "zqllm"}))
|
||||||
|
pages := out["pages"].([]any)
|
||||||
|
require.NotEmpty(t, pages)
|
||||||
|
for _, p := range pages {
|
||||||
|
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resultText decodes the tool result payload of a tools/call response.
|
||||||
|
func resultText(t *testing.T, resp map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
require.NotContains(t, resp, "error", "tool call failed: %v", resp["error"])
|
||||||
|
content := resp["result"].(map[string]any)["content"].([]any)
|
||||||
|
var out map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNote(t *testing.T, dir, rel string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frontmatterOf(note string) string {
|
||||||
|
parts := strings.SplitN(note, "---\n", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#145: a brain entry records which principal wrote it, so a reader
|
||||||
|
// can tell a local-CLI note from one a connector session wrote after reading
|
||||||
|
// an untrusted page.
|
||||||
|
func TestBrainWriteStampsPrincipalProvenance(t *testing.T) {
|
||||||
|
srv, dir, _, _ := govServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-static",
|
||||||
|
}))
|
||||||
|
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
|
||||||
|
assert.Contains(t, fm, "origin: static\n")
|
||||||
|
assert.Contains(t, fm, "written_by: local-cli\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteWithoutPrincipalStampsOriginUnknown(t *testing.T) {
|
||||||
|
srv, dir, _, _ := govServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
|
||||||
|
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-unknown",
|
||||||
|
}))
|
||||||
|
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
|
||||||
|
assert.Contains(t, fm, "origin: unknown\n")
|
||||||
|
assert.NotContains(t, fm, "written_by:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control: a caller cannot claim a more trusted origin by putting it in
|
||||||
|
// its own frontmatter.
|
||||||
|
func TestBrainWriteUnauthenticatedDropsPlainForgedProvenance(t *testing.T) {
|
||||||
|
srv, dir, _, _ := govServer(t)
|
||||||
|
forged := "---\norigin: static\nwritten_by: local-cli\n---\n# F\n\nbody"
|
||||||
|
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
|
||||||
|
"content": forged, "wing": "testing", "hall": "facts", "filename": "prov-forged",
|
||||||
|
}))
|
||||||
|
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
|
||||||
|
assert.Contains(t, fm, "origin: unknown\n")
|
||||||
|
assert.NotContains(t, fm, "origin: static")
|
||||||
|
assert.NotContains(t, fm, "written_by: local-cli")
|
||||||
|
assert.Equal(t, 1, strings.Count(fm, "origin:"), "exactly one origin line")
|
||||||
|
}
|
||||||
|
|
||||||
|
// brain_update replaces the content wholesale, so provenance follows the
|
||||||
|
// last writer — and a forged fragment must not survive the merge either.
|
||||||
|
func TestBrainUpdateRestampsProvenanceToTheUpdater(t *testing.T) {
|
||||||
|
srv, dir, _, _ := govServer(t)
|
||||||
|
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# U\n\nv1", "wing": "testing", "hall": "facts", "filename": "prov-update",
|
||||||
|
}))
|
||||||
|
path := out["path"].(string)
|
||||||
|
resultText(t, authCall(t, srv, "", "brain_update", map[string]any{
|
||||||
|
"path": path, "content": "---\norigin: static\nwritten_by: local-cli\n---\n# U\n\nv2",
|
||||||
|
}))
|
||||||
|
fm := frontmatterOf(readNote(t, dir, path))
|
||||||
|
assert.Contains(t, fm, "origin: unknown\n")
|
||||||
|
assert.NotContains(t, fm, "written_by: local-cli")
|
||||||
|
assert.Equal(t, 1, strings.Count(fm, "origin:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainQueryReturnsProvenance(t *testing.T) {
|
||||||
|
srv, _, _, _ := govServer(t)
|
||||||
|
resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||||
|
"content": "# Zebrafinch\n\nzebrafinch provenance probe", "wing": "testing", "hall": "facts", "filename": "prov-query",
|
||||||
|
}))
|
||||||
|
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{"query": "zebrafinch"}))
|
||||||
|
results := out["results"].([]any)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
hit := results[0].(map[string]any)
|
||||||
|
assert.Equal(t, "static", hit["origin"])
|
||||||
|
assert.Equal(t, "local-cli", hit["written_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// capture is the door a claude.ai connector session uses, so it is the one
|
||||||
|
// whose provenance matters most: a JWT principal must be stamped as such.
|
||||||
|
func TestMCPCaptureStampsJWTProvenance(t *testing.T) {
|
||||||
|
srv, brainDir := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
|
||||||
|
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "Capture provenance probe", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"], "got error: %v", resp["error"])
|
||||||
|
matches, err := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, matches, 1)
|
||||||
|
b, err := os.ReadFile(matches[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
fm := frontmatterOf(string(b))
|
||||||
|
assert.Contains(t, fm, "origin: jwt\n")
|
||||||
|
assert.Contains(t, fm, "written_by: koala-cli\n")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user