Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09aeaa91f5 | ||
|
|
9abe4369f2 | ||
|
|
60a8801a1d |
+1
-5
@@ -64,11 +64,7 @@ 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, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`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,29 +7,6 @@
|
|||||||
"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}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-160
@@ -53,51 +53,14 @@ 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.
|
||||||
|
|
||||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
the tiered merge model in `assessor-loop`
|
session. Do not leave agent branches open between sessions.
|
||||||
`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.
|
|
||||||
|
|
||||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
collisions between parallel sessions: two were not branching problems at all — a
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
generated artifact written to the same path by both (branching turns that into a
|
the reason in PROJECT.md.
|
||||||
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:
|
||||||
@@ -115,55 +78,6 @@ 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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -234,10 +148,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 | Apple Silicon Mac, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/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 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
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -304,16 +218,10 @@ 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)** | 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":"..."}` |
|
| **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":"..."}` |
|
||||||
| **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 — the brain holds no confidential client content.** Nothing on the read path
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
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
|
||||||
@@ -339,11 +247,10 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
This context is designed to work with any AI coding tool:
|
||||||
re-add adapters for them:
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
- 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.
|
||||||
@@ -355,65 +262,26 @@ 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`, `.context/system-prompt.txt`
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.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. Which file a harness reads does
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
- `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 on every
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
host and every harness.
|
unconditionally on every host, 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`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
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.
|
||||||
|
|
||||||
**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:**
|
||||||
|
|
||||||
@@ -493,11 +361,7 @@ 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, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`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,6 +1,7 @@
|
|||||||
# 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
|
||||||
|
|||||||
+1
-15
@@ -45,12 +45,6 @@ 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
|
||||||
@@ -81,19 +75,11 @@ 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"
|
||||||
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
|
||||||
# finds the bump already applied failed the whole job and rang the
|
|
||||||
# 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
|
|
||||||
# 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 commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ 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]
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
+5
-13
@@ -56,16 +56,8 @@ paths = [
|
|||||||
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
[[allowlists]]
|
||||||
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)"
|
||||||
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
condition = "AND"
|
||||||
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
|
||||||
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
regexes = ['''REDACTED''']
|
||||||
# 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.
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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,51 +48,14 @@ 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.
|
||||||
|
|
||||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
the tiered merge model in `assessor-loop`
|
session. Do not leave agent branches open between sessions.
|
||||||
`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.
|
|
||||||
|
|
||||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
collisions between parallel sessions: two were not branching problems at all — a
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
generated artifact written to the same path by both (branching turns that into a
|
the reason in PROJECT.md.
|
||||||
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:
|
||||||
@@ -110,55 +73,6 @@ 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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -229,10 +143,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 | Apple Silicon Mac, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/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 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
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -299,16 +213,10 @@ 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)** | 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":"..."}` |
|
| **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":"..."}` |
|
||||||
| **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 — the brain holds no confidential client content.** Nothing on the read path
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
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
|
||||||
@@ -334,11 +242,10 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
This context is designed to work with any AI coding tool:
|
||||||
re-add adapters for them:
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
- 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.
|
||||||
@@ -350,65 +257,26 @@ 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`, `.context/system-prompt.txt`
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.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. Which file a harness reads does
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
- `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 on every
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
host and every harness.
|
unconditionally on every host, 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`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
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.
|
||||||
|
|
||||||
**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:**
|
||||||
|
|
||||||
@@ -488,11 +356,7 @@ 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, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`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,11 +64,7 @@ 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, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`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 +1,2 @@
|
|||||||
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/
|
||||||
|
|||||||
@@ -100,39 +100,8 @@ review gate.
|
|||||||
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||||
does not commit noise that hides real edits.
|
does not commit noise that hides real edits.
|
||||||
|
|
||||||
Two fields are **generator-owned** and do get refreshed on a revise:
|
Known limitation: because the on-disk frontmatter wins, `last_updated` keeps its
|
||||||
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
first-ingest date on a revised page.
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
+27
-29
@@ -21,7 +21,7 @@ tasks:
|
|||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
start:
|
start:
|
||||||
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
||||||
cmds:
|
cmds:
|
||||||
- goreman start
|
- goreman start
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ 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:
|
||||||
@@ -62,9 +67,14 @@ tasks:
|
|||||||
build:
|
build:
|
||||||
desc: Build all binaries
|
desc: Build all binaries
|
||||||
cmds:
|
cmds:
|
||||||
- task: hyperguild:build
|
- task: supervisor: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
|
||||||
@@ -74,28 +84,10 @@ tasks:
|
|||||||
# ── Quality ────────────────────────────────────────────────────────────────
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
- task: context:sync
|
||||||
# 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: |
|
||||||
if [ -n "${CI:-}" ]; then
|
|
||||||
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
|
||||||
else
|
|
||||||
bash scripts/context-sync.sh
|
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
if [ -n "$drift" ]; then
|
if [ -n "$drift" ]; then
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
@@ -106,16 +98,9 @@ tasks:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ context: canonical and adapters are in sync"
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
fi
|
|
||||||
- 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
|
- task: security:gitleaks
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────
|
# ── Security ─────────────────────────────────────────────
|
||||||
@@ -169,6 +154,19 @@ 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,21 +55,18 @@ 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 <wing> <hall> <slug>`
|
### `hyperguild brain write <type> <slug>`
|
||||||
|
|
||||||
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
|
Reads markdown from stdin, writes a knowledge entry.
|
||||||
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 homelab facts example-lesson
|
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
||||||
# Example lesson
|
# Example lesson
|
||||||
|
|
||||||
## Lesson
|
## Lesson
|
||||||
...
|
...
|
||||||
EOF
|
EOF
|
||||||
wiki/homelab/facts/example-lesson.md
|
knowledge/example-lesson.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hyperguild brain pass-rate <skill>`
|
### `hyperguild brain pass-rate <skill>`
|
||||||
|
|||||||
@@ -60,14 +60,13 @@ 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() < 3 {
|
if fs.NArg() < 2 {
|
||||||
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
|
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
||||||
}
|
}
|
||||||
wing := fs.Arg(0)
|
kind := fs.Arg(0)
|
||||||
hall := fs.Arg(1)
|
slug := fs.Arg(1)
|
||||||
slug := fs.Arg(2)
|
|
||||||
|
|
||||||
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
|
res, err := newBrainClient().Write(ctx, kind, 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", "homelab", "facts", "test-slug"},
|
[]string{"write", "knowledge", "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", "homelab"}, strings.NewReader("x"), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "wing, hall and slug required")
|
assert.Contains(t, err.Error(), "type 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", "homelab", "facts", "bad slug"},
|
[]string{"write", "knowledge", "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", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "knowledge", "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")
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-20
@@ -20,7 +20,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ 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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +62,8 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -85,17 +84,16 @@ type WriteResult struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
|
func (c *brainClient) Write(ctx context.Context, kind, slug 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 {
|
||||||
Wing string `json:"wing"`
|
Type string `json:"type"`
|
||||||
Hall string `json:"hall"`
|
Slug string `json:"slug"`
|
||||||
Filename string `json:"filename"`
|
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
|
}{Type: kind, Slug: slug, 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)
|
||||||
}
|
}
|
||||||
@@ -106,7 +104,8 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -143,7 +142,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.do(req)
|
resp, err := c.http.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)
|
||||||
}
|
}
|
||||||
@@ -158,13 +157,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,25 +65,23 @@ 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 {
|
||||||
Wing string `json:"wing"`
|
Type string `json:"type"`
|
||||||
Hall string `json:"hall"`
|
Slug string `json:"slug"`
|
||||||
Filename string `json:"filename"`
|
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(body, &got))
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
assert.Equal(t, "homelab", got.Wing)
|
assert.Equal(t, "knowledge", got.Type)
|
||||||
assert.Equal(t, "facts", got.Hall)
|
assert.Equal(t, "find-h", got.Slug)
|
||||||
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":"wiki/homelab/facts/find-h.md"}`))
|
_, _ = w.Write([]byte(`{"path":"knowledge/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(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
|
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
|
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainClient_PassRate_Success(t *testing.T) {
|
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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>` | Bearer `BRAIN_MCP_TOKEN` (since hyperguild#149) |
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||||
|
|
||||||
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 <REDACTED-dead-gitea-pat-infra#344>" \
|
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
||||||
| 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 <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 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 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 <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.)
|
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
||||||
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:
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import (
|
|||||||
"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/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"
|
||||||
@@ -148,38 +147,6 @@ func envInt(key string, fallback int) int {
|
|||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
// 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
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
// 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 {
|
func buildNotifier() audit.Notifier {
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
return n
|
return n
|
||||||
@@ -236,15 +203,7 @@ 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")
|
||||||
@@ -277,11 +236,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -366,8 +320,6 @@ 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
|
// Built before the watcher, the graph backfill and session ingestion, and
|
||||||
// retried rather than fatal (#83). Two separate reasons:
|
// retried rather than fatal (#83). Two separate reasons:
|
||||||
@@ -439,29 +391,6 @@ 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{
|
||||||
@@ -471,7 +400,6 @@ func main() {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -511,10 +439,20 @@ func main() {
|
|||||||
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public routes only; buildMux mounts the authenticated REST API (#149).
|
mux := http.NewServeMux()
|
||||||
public := map[string]http.Handler{}
|
mux.HandleFunc("POST /query", h.Query)
|
||||||
|
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 {
|
||||||
public["POST /webhooks/brain-sync"] = webhookHandler
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
}
|
}
|
||||||
// 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
|
||||||
@@ -522,14 +460,14 @@ func main() {
|
|||||||
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")
|
||||||
public["GET /.well-known/oauth-protected-resource"] = http.HandlerFunc(
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
|
mux.Handle("/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
|
||||||
@@ -551,7 +489,7 @@ func main() {
|
|||||||
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)
|
||||||
public["POST /capture"] = captureH
|
mux.Handle("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;
|
||||||
@@ -581,9 +519,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)
|
||||||
}
|
}
|
||||||
public["GET /.well-known/oauth-authorization-server"] = http.HandlerFunc(
|
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
|
||||||
oauth.MetadataHandler(issuer))
|
oauth.MetadataHandler(issuer))
|
||||||
public["POST /oauth/token"] = http.HandlerFunc(oauth.TokenHandler(oauth.TokenConfig{
|
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
|
||||||
ClientID: oauthID,
|
ClientID: oauthID,
|
||||||
ClientSecret: oauthSecret,
|
ClientSecret: oauthSecret,
|
||||||
AccessToken: mcpToken,
|
AccessToken: mcpToken,
|
||||||
@@ -602,17 +540,7 @@ 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()
|
||||||
public["GET /metrics"] = http.HandlerFunc(reg.Handler())
|
mux.HandleFunc("GET /metrics", 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,7 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -37,42 +36,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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,12 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import "strings"
|
||||||
"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
|
||||||
@@ -31,37 +25,21 @@ 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
|
||||||
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
if !strings.HasPrefix(src, "---\n") {
|
||||||
if !ok {
|
|
||||||
return fm, src
|
return fm, src
|
||||||
}
|
}
|
||||||
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
rest := src[len("---\n"):]
|
||||||
// package's always has: block keeps the "\n" immediately before the
|
end := strings.Index(rest, "\n---\n")
|
||||||
// closing fence, and rest keeps the "\n" immediately after it (both by
|
if end < 0 {
|
||||||
// design, for Fix's own reconstruction). This package's block/body
|
// Opening fence with no closing fence — treat as bodyless content.
|
||||||
// never included either — TrimSuffix/TrimPrefix restore that exact
|
return fm, src
|
||||||
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
}
|
||||||
// both trims are always exactly one byte, never ambiguous.
|
block := rest[:end]
|
||||||
block := strings.TrimSuffix(string(blockBytes), "\n")
|
body := rest[end+len("\n---\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), "#") {
|
||||||
@@ -105,19 +83,9 @@ 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 {
|
||||||
switch {
|
if l.key == "" {
|
||||||
case l.key == "":
|
|
||||||
b.WriteString(l.raw)
|
b.WriteString(l.raw)
|
||||||
case l.value == "":
|
} else {
|
||||||
// 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,37 +59,3 @@ 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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"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,6 +54,8 @@ 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
|
||||||
@@ -107,21 +110,20 @@ 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.
|
||||||
//
|
//
|
||||||
// Wing and Hall are both required: the note routes into the structured
|
// When both Wing and Hall are non-empty, the note routes into the
|
||||||
// wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets wing/hall/created_at
|
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||||
// injected into its YAML frontmatter. There is no other valid destination
|
// wing/hall/created_at injected into its YAML frontmatter.
|
||||||
// (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
|
||||||
@@ -130,22 +132,15 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// In the primitive, not in a caller: POST /write reaches here directly,
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
opts.Content = clean
|
|
||||||
|
|
||||||
|
if opts.Wing != "" && opts.Hall != "" {
|
||||||
return writeHallNote(brainDir, opts)
|
return writeHallNote(brainDir, opts)
|
||||||
}
|
}
|
||||||
|
if opts.Wing != "" || opts.Hall != "" {
|
||||||
|
return "", fmt.Errorf("wing and hall must be set together")
|
||||||
|
}
|
||||||
|
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
|
||||||
// wing/hall/created_at frontmatter.
|
// wing/hall/created_at frontmatter.
|
||||||
@@ -173,6 +168,9 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("claim envelope: %w", err)
|
return "", fmt.Errorf("claim envelope: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
|
}
|
||||||
emitted := make(map[string]bool, 6)
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
@@ -199,6 +197,9 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writeField("type", opts.Type)
|
||||||
|
writeField("domain", opts.Domain)
|
||||||
|
|
||||||
sourceType := opts.SourceType
|
sourceType := opts.SourceType
|
||||||
if sourceType == "" && opts.Hall == "facts" {
|
if sourceType == "" && opts.Hall == "facts" {
|
||||||
// Most hall=facts entries are first-party (an eval/benchmark the
|
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||||
@@ -220,17 +221,7 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||||
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)
|
||||||
@@ -299,33 +290,70 @@ func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
|||||||
return fields, body
|
return fields, body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write handles POST /write — write a note into brain/wiki/<wing>/<hall>/.
|
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||||
|
// callers that have not adopted the wing/hall taxonomy.
|
||||||
|
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
|
filename := opts.Filename
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDir := filepath.Join(brainDir, "knowledge")
|
||||||
|
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 != "" {
|
||||||
|
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||||
|
}
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
finalContent = fm.String() + opts.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
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/.
|
||||||
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
|
||||||
}
|
}
|
||||||
// Field by field, not a struct conversion: provenance is server-owned and
|
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||||
// 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
|
||||||
}
|
}
|
||||||
// WriteNote errors out above unless both are set, so this always runs —
|
if req.Wing != "" && req.Hall != "" {
|
||||||
// no legacy fallback path left to guard against.
|
|
||||||
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||||
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,12 +474,6 @@ 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 {
|
||||||
@@ -474,13 +496,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !isSupportedExtension(ext) {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Each walked file is re-resolved: a symlinked file inside raw/
|
content, readErr := extract.Text(path)
|
||||||
// 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
|
||||||
@@ -509,7 +525,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
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 := ReadIngestFile(h.brainDir, req.Path)
|
content, readErr := extract.Text(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
|
||||||
@@ -561,8 +577,7 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// No principal on this door: pages are stamped origin: unknown (#145).
|
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||||
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")
|
||||||
@@ -618,11 +633,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Field by field: this door carries no principal, so the promoted note is
|
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||||
// 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,13 +65,11 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
|||||||
assert.NotEmpty(t, results)
|
assert.NotEmpty(t, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_CreatesWikiFile(t *testing.T) {
|
func TestWrite_CreatesKnowledgeFile(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",
|
"filename": "test-note.md",
|
||||||
"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()
|
||||||
@@ -83,25 +81,11 @@ func TestWrite_CreatesWikiFile(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, "wiki", "homelab", "facts", "test-note.md"))
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "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})
|
||||||
@@ -113,6 +97,27 @@ 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()
|
||||||
|
|
||||||
@@ -121,6 +126,7 @@ 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)
|
||||||
@@ -204,95 +210,37 @@ func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testin
|
|||||||
assert.Contains(t, body, "# Body")
|
assert.Contains(t, body, "# Body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract
|
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||||
// (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()
|
dir := t.TempDir()
|
||||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
|
||||||
Content: "body",
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
Wing: tc.wing,
|
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||||
Hall: tc.hall,
|
Filename: "note",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "decisions",
|
||||||
|
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, err.Error(), "wing and hall are required")
|
|
||||||
})
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
}
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "type: hypothesis")
|
||||||
|
assert.NotContains(t, string(got), "type: decision")
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
"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, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts"))
|
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||||
require.NoError(t, err)
|
// +1 because setup already wrote tdd.md
|
||||||
require.Len(t, entries, 1)
|
assert.Len(t, entries, 2)
|
||||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
assert.True(t, strings.HasSuffix(entries[1].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.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -361,11 +309,10 @@ func TestIngestPath_MissingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_File(t *testing.T) {
|
func TestIngestPath_File(t *testing.T) {
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
// Create a temp file with content
|
// Create a temp file with content
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
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))
|
||||||
|
|
||||||
@@ -469,11 +416,10 @@ func TestIngestRaw_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_Directory(t *testing.T) {
|
func TestIngestPath_Directory(t *testing.T) {
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
// Create a temp dir with one .md file
|
// Create a temp dir with one .md file
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
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{
|
||||||
@@ -551,45 +497,3 @@ func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
|||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
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]]")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
|||||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
f := filepath.Join(dir, "doc.docx")
|
f := filepath.Join(dir, "doc.docx")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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,9 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -30,25 +28,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) {
|
||||||
root, _, err := OpenRawRoot(brainDir)
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if os.IsNotExist(err) {
|
||||||
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.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
continue
|
||||||
}
|
}
|
||||||
raw, info, readErr := readRawNote(root, e.Name())
|
info, statErr := e.Info()
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -74,10 +72,6 @@ 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
|
||||||
@@ -107,9 +101,12 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
src := filepath.Join(brainDir, "raw", base)
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
raw, err := readRawSource(brainDir, base)
|
raw, err := os.ReadFile(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
if os.IsNotExist(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.
|
||||||
@@ -131,11 +128,7 @@ 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.
|
||||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
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 {
|
||||||
@@ -152,65 +145,6 @@ 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)
|
||||||
@@ -220,24 +154,3 @@ 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,85 +119,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"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"
|
|
||||||
|
|
||||||
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)
|
|
||||||
var stop atomic.Bool
|
|
||||||
go swap(raw, outside, false, &stop)
|
|
||||||
defer stop.Store(true)
|
|
||||||
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)
|
|
||||||
var stop atomic.Bool
|
|
||||||
go 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:
|
|
||||||
stop.Store(true)
|
|
||||||
case <-time.After(6 * time.Second):
|
|
||||||
stop.Store(true)
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
var stop atomic.Bool
|
|
||||||
go func() {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
defer stop.Store(true)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"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
|
||||||
@@ -46,11 +45,6 @@ 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
|
||||||
@@ -70,14 +64,6 @@ 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
|
||||||
@@ -104,65 +90,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the envelope AFTER the merge, against the full field set that
|
out := []byte(fm.render() + opts.Content)
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,114 +52,6 @@ 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",
|
||||||
@@ -231,45 +123,6 @@ 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")))
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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,24 +54,11 @@ 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"))
|
||||||
title = strings.TrimSpace(title)
|
needle := strings.ToLower(strings.TrimSpace(title))
|
||||||
if title == "" {
|
if needle == "" {
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -82,7 +69,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(haystack, idx, len(needle)),
|
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -92,11 +79,6 @@ 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 {
|
||||||
|
|||||||
@@ -101,28 +101,15 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
|
||||||
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
|
||||||
|
|
||||||
// Substring of title appears in content, but not as a whole word.
|
|
||||||
candidates, err := brain.DetectTunnels(dir, "the routing tables were rebuilt")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, candidates, 1)
|
|
||||||
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()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||||
"---\ntitle: Routing\n---\nbody.\n")
|
"---\ntitle: Routing\n---\nbody.\n")
|
||||||
|
|
||||||
|
// 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, "rerouting handles failover")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, candidates)
|
require.Len(t, candidates, 1)
|
||||||
|
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||||
@@ -139,21 +126,20 @@ 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 Table".
|
// Existing note in a different wing whose title is "Routing".
|
||||||
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
seedNote(t, dir, "wiki/other/facts/routing.md",
|
||||||
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
||||||
// Source note in another wing whose body mentions "routing tables"
|
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
||||||
// (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---\nrouting tables rebuilt\n")
|
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
||||||
|
|
||||||
require.NoError(t, brain.AutoTunnel(dir,
|
require.NoError(t, brain.AutoTunnel(dir,
|
||||||
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
||||||
|
|
||||||
// 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-table]]")
|
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
||||||
|
|
||||||
// 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"))
|
||||||
@@ -162,7 +148,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-table.md")
|
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
||||||
assert.Contains(t, string(body), "Routing")
|
assert.Contains(t, string(body), "Routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,35 +175,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,19 +42,13 @@ 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,
|
||||||
|
Domain: n.Domain,
|
||||||
Wing: n.Wing,
|
Wing: n.Wing,
|
||||||
Hall: n.Hall,
|
Hall: n.Hall,
|
||||||
Origin: n.Origin,
|
|
||||||
WrittenBy: n.WrittenBy,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return capture.Ref{}, err
|
return capture.Ref{}, err
|
||||||
@@ -63,20 +57,14 @@ 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, Unlinked: unlinked}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, 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) {
|
||||||
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
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 {
|
||||||
@@ -92,7 +80,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, Unlinked: unlinked}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, 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,45 +83,3 @@ 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]]")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ 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
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ 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
|
||||||
@@ -31,20 +27,18 @@ type StoredNote struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note is the brain-write payload: wing+hall are the only routing fields
|
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||||
// (the legacy type/domain params were removed — ADR-0012 / #79). Reason is
|
// and the legacy type/domain fields so a single BrainStore serves both
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -142,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, in.Context)
|
res, item, err := s.persistInsight(ctx, ins)
|
||||||
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()})
|
||||||
@@ -278,11 +278,8 @@ func escalationReason(escalators []string) string {
|
|||||||
return "required by: " + strings.Join(escalators, ", ")
|
return "required by: " + strings.Join(escalators, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistInsight(ctx context.Context, ins Insight, cc CaptureContext) (InsightResult, string, error) {
|
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||||
note := Note{
|
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||||
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 != "" {
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ 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 {
|
||||||
|
|||||||
@@ -38,11 +38,9 @@ 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
|
||||||
}
|
}
|
||||||
@@ -185,7 +183,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,26 +43,6 @@ 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,64 +46,6 @@ 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{
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
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,25 +45,6 @@ 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()
|
||||||
@@ -87,9 +68,6 @@ 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,
|
||||||
@@ -113,7 +91,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, cfg.SkipProjects, cfg.Logger)
|
files, err := listSessionFiles(cfg.SessionsDir)
|
||||||
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
|
||||||
@@ -141,52 +119,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSkipPatterns rejects a pattern filepath.Match cannot parse.
|
func listSessionFiles(root string) ([]string, error) {
|
||||||
//
|
|
||||||
// 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") {
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,8 @@ 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.
|
||||||
@@ -43,70 +41,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ func Fix(doc []byte) ([]byte, bool, error) {
|
|||||||
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, rest, _ := Split(doc)
|
_, rest, _ := split(doc)
|
||||||
out := []byte(fence + "\n")
|
out := []byte(fence + "\n")
|
||||||
out = append(out, fixed...)
|
out = append(out, fixed...)
|
||||||
out = append(out, []byte(fence)...)
|
out = append(out, []byte(fence)...)
|
||||||
@@ -122,25 +122,14 @@ func needsQuoting(value string) bool {
|
|||||||
|
|
||||||
// block returns the raw YAML between the opening and closing fences.
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
func block(doc []byte) ([]byte, bool) {
|
func block(doc []byte) ([]byte, bool) {
|
||||||
b, _, ok := Split(doc)
|
b, _, ok := split(doc)
|
||||||
return b, ok
|
return b, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split returns doc's frontmatter block and everything from the closing
|
// split returns the frontmatter block and everything from the closing fence
|
||||||
// fence onward (the closing fence itself excluded, its trailing newline
|
// onward (the closing fence itself excluded, its trailing newline included).
|
||||||
// included), or ok=false if doc has no properly-fenced frontmatter.
|
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
//
|
s := string(doc)
|
||||||
// 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") {
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
@@ -150,18 +139,5 @@ func Split(doc []byte) (block, rest []byte, ok bool) {
|
|||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
// What follows the closing fence must be either a newline (a real body
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ func TestFix_PreservesBodyExactly(t *testing.T) {
|
|||||||
require.True(t, changed)
|
require.True(t, changed)
|
||||||
assert.True(t, len(got) > 0)
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
_, rest, ok := Split([]byte(doc))
|
_, rest, ok := split([]byte(doc))
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
_, gotRest, ok := Split(got)
|
_, gotRest, ok := split(got)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
}
|
}
|
||||||
@@ -186,55 +186,3 @@ func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
|||||||
assert.False(t, changed)
|
assert.False(t, changed)
|
||||||
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,18 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router calls Primary first; on any error falls back to Fallback, then to
|
// Router calls Primary first; on any error falls back to Fallback.
|
||||||
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
// Fallback may be nil, in which case primary errors are returned directly.
|
||||||
// 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
|
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||||
// 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 {
|
||||||
@@ -26,15 +22,8 @@ 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,78 +69,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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...)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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"])
|
|
||||||
}
|
|
||||||
@@ -12,10 +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/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"
|
||||||
)
|
)
|
||||||
@@ -53,12 +53,14 @@ func (s *Server) tools() []map[string]any {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_write",
|
"name": "brain_write",
|
||||||
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
|
"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).",
|
||||||
"inputSchema": schema([]string{"content", "wing", "hall"}, map[string]any{
|
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||||
"content": str("markdown content"),
|
"content": str("markdown content"),
|
||||||
"filename": str("optional filename or slug"),
|
"filename": str("optional filename or slug"),
|
||||||
"wing": str("topic domain, e.g. jepa-fx"),
|
"type": str("optional frontmatter type (legacy)"),
|
||||||
"hall": enum("memory type", halls...),
|
"domain": str("optional frontmatter domain (legacy)"),
|
||||||
|
"wing": str("optional topic domain, e.g. jepa-fx"),
|
||||||
|
"hall": enum("optional memory type (requires wing)", halls...),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,14 +98,6 @@ 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()[0],
|
||||||
noteToolDescriptors()[1],
|
noteToolDescriptors()[1],
|
||||||
{
|
{
|
||||||
@@ -233,19 +227,12 @@ 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 {
|
||||||
@@ -255,24 +242,18 @@ 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,
|
||||||
|
Domain: a.Domain,
|
||||||
Wing: a.Wing,
|
Wing: a.Wing,
|
||||||
Hall: a.Hall,
|
Hall: a.Hall,
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
return json.Marshal(map[string]string{"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 {
|
||||||
@@ -307,26 +288,18 @@ 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
|
||||||
}
|
}
|
||||||
out := map[string]any{
|
return json.Marshal(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 {
|
||||||
@@ -393,10 +366,8 @@ 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
|
||||||
@@ -405,26 +376,6 @@ 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
|
||||||
@@ -499,7 +450,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(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
|
result, err := pipeline.RunRaw(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)
|
||||||
}
|
}
|
||||||
@@ -542,13 +493,7 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
// Confined to brain/raw/ with symlinks evaluated (hyperguild#149).
|
text, err := extract.Text(a.Path)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -604,7 +549,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(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
|
result, err := pipeline.Run(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)
|
||||||
}
|
}
|
||||||
@@ -623,10 +568,3 @@ 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,50 +51,25 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
assert.Contains(t, text, "tdd.md")
|
assert.Contains(t, text, "tdd.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
|
func TestBrainWriteCreatesFile(t *testing.T) {
|
||||||
// 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",
|
"filename": "test.md",
|
||||||
"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, "wiki", "homelab", "facts", "test.md"))
|
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(got), "type: note")
|
assert.Contains(t, string(got), "type: note")
|
||||||
assert.NotContains(t, string(got), "domain: personal")
|
assert.Contains(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)
|
||||||
@@ -233,11 +208,30 @@ func TestBrainTunnelRejectsMissing(t *testing.T) {
|
|||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filename traversal/sanitisation on the wiki route is covered directly
|
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
||||||
// against brain.NotePath/Sanitise in internal/brain/path_test.go
|
brainDir := t.TempDir()
|
||||||
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
// the now-deleted brain/knowledge/ legacy path's own separate filename
|
|
||||||
// validation, which had no equivalent once that path was removed (#79).
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"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()
|
||||||
@@ -396,38 +390,3 @@ 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"])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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/")
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
@@ -230,9 +229,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx = withPrincipal(ctx, principal, viaStatic)
|
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||||
logToolCall(ctx, p.Name, p.Arguments, start, err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
break
|
break
|
||||||
@@ -278,8 +275,6 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainPending(ctx, args)
|
return s.brainPending(ctx, args)
|
||||||
case "brain_promote":
|
case "brain_promote":
|
||||||
return s.brainPromote(ctx, args)
|
return s.brainPromote(ctx, args)
|
||||||
case "brain_reject":
|
|
||||||
return s.brainReject(ctx, args)
|
|
||||||
case "capture":
|
case "capture":
|
||||||
return s.brainCapture(ctx, args)
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_read":
|
case "brain_read":
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
"brain_pending", "brain_promote", "brain_reject",
|
"brain_pending", "brain_promote",
|
||||||
"brain_read", "brain_edit",
|
"brain_read", "brain_edit",
|
||||||
"brain_tunnel",
|
"brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
@@ -134,11 +134,9 @@ type stubTracker struct{}
|
|||||||
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
// Principal and origin are server-derived — never taken from the body.
|
// Principal and origin are server-derived — never taken from the body.
|
||||||
in.Context.Principal = info.principal
|
in.Context.Principal = info.principal
|
||||||
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
||||||
in.Context.AuthPath, _ = provenanceFrom(ctx)
|
|
||||||
|
|
||||||
rec, err := s.capture.svc.Capture(ctx, in)
|
rec, err := s.capture.svc.Capture(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,17 +99,3 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
return json.Marshal(rec)
|
return json.Marshal(rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// provenanceFrom maps the authenticated principal in ctx to the provenance
|
|
||||||
// stamped on a brain write (hyperguild#145). No principal → "unknown", never
|
|
||||||
// silence: an unstamped note must not read as trusted.
|
|
||||||
func provenanceFrom(ctx context.Context) (origin, writtenBy string) {
|
|
||||||
info, ok := ctx.Value(principalKey).(principalInfo)
|
|
||||||
if !ok {
|
|
||||||
return "unknown", ""
|
|
||||||
}
|
|
||||||
if info.viaStatic {
|
|
||||||
return "static", info.principal
|
|
||||||
}
|
|
||||||
return "jwt", info.principal
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ type capFakeTracker struct{}
|
|||||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
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 (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,14 @@ package mcp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/claim"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,41 +100,10 @@ type brainEditArgs struct {
|
|||||||
// hard the change is to reconstruct. Auditing every note edit would also bury
|
// hard the change is to reconstruct. Auditing every note edit would also bury
|
||||||
// the rare governance record under routine traffic, which is how an alert
|
// the rare governance record under routine traffic, which is how an alert
|
||||||
// stops being read.
|
// stops being read.
|
||||||
func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
// Snapshot before editing: if the edited note cannot be stamped and
|
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
||||||
// verified, the edit is rolled back rather than left on disk unattributed
|
|
||||||
// (or carrying whatever provenance the edit wrote).
|
|
||||||
var a brainEditArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
target, err := notes.Resolve(s.brainDir, a.Path, notes.ScopeNotes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// No snapshot, no edit: without one a failed stamp could not be undone.
|
|
||||||
prior, err := os.ReadFile(target.Abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("snapshot before edit: %w", err)
|
|
||||||
}
|
|
||||||
res, out, err := s.editWithScope(args, notes.ScopeNotes)
|
|
||||||
if err != nil {
|
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
// A byte-level replace can rewrite any line, including provenance, and
|
|
||||||
// whoever edits a note is the author of what a reader now sees: re-stamp
|
|
||||||
// to the editor after every edit (PR #147 review, escape E1).
|
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
|
||||||
if serr := api.StampProvenanceFile(s.brainDir, res.Path, origin, writtenBy); serr != nil {
|
|
||||||
if werr := os.WriteFile(target.Abs, prior, 0o644); werr != nil {
|
|
||||||
// Say exactly what state the note is in: edited, unverified.
|
|
||||||
return nil, fmt.Errorf("stamp provenance failed (%v) AND the edit could NOT be rolled back: %w; "+
|
|
||||||
"%s carries the edit under its previous attribution", serr, werr, res.Path)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("edit rolled back, stamp provenance: %w", serr)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
||||||
// the authenticated principal: only the static local-CLI token may call it, so
|
// the authenticated principal: only the static local-CLI token may call it, so
|
||||||
@@ -214,29 +178,8 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#123: brain_write/brain_update both validate the claim
|
|
||||||
// envelope before writing (hyperguild#111: "same rules when a note
|
|
||||||
// changes as when it's born"); brain_edit didn't — a byte-level string
|
|
||||||
// replace has no frontmatter awareness at all, so it could rewrite an
|
|
||||||
// existing half_life:/applies:/claim_provenance: line straight past the
|
|
||||||
// validator entirely. Scoped to notes only (governance files have no
|
|
||||||
// claim-envelope concept).
|
|
||||||
if scope == notes.ScopeNotes {
|
|
||||||
if verr := checkClaimEnvelope(s.brainDir, a.Path, a.OldStr, a.NewStr); verr != nil {
|
|
||||||
return notes.Result{}, nil, fmt.Errorf("claim envelope: %w", verr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
|
||||||
// hyperguild#115: "old_str is empty" alone doesn't say whether the
|
|
||||||
// caller sent it empty or a hop between the caller and here lost
|
|
||||||
// it. Naming the raw argument byte count makes that distinguishable
|
|
||||||
// without a second investigation.
|
|
||||||
return notes.Result{}, nil, fmt.Errorf("%w (raw arguments were %d bytes)", err, len(args))
|
|
||||||
}
|
|
||||||
return notes.Result{}, nil, err
|
return notes.Result{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,156 +220,3 @@ func touchesFrontmatter(oldStr, newStr string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkClaimEnvelope previews what old_str->new_str would produce — the
|
|
||||||
// exact strings.Replace(before, oldStr, newStr, 1) formula notes.Edit
|
|
||||||
// itself uses internally — and validates the resulting frontmatter's claim
|
|
||||||
// envelope before the real edit is allowed to run. Fail-closed: an invalid
|
|
||||||
// result blocks the edit entirely, the same posture writeHallNote/UpdateNote
|
|
||||||
// already apply on create/supersede.
|
|
||||||
//
|
|
||||||
// Gated on old_str appearing within the note's frontmatter BLOCK, not on
|
|
||||||
// old_str/new_str mentioning a claim-envelope field NAME — found in review:
|
|
||||||
// a field-name-substring gate misses a value-only edit like
|
|
||||||
// "asserted"->"guessed" on a claim_provenance line, which never mentions
|
|
||||||
// the field name at all. That is exactly the shape of edit a minimal,
|
|
||||||
// natural diff produces, so a field-name gate was closing the issue in
|
|
||||||
// name only. Checking block containment instead means any edit that
|
|
||||||
// couldn't possibly touch the frontmatter (its target text only exists in
|
|
||||||
// the body) is still skipped cheaply, while nothing inside the fence can
|
|
||||||
// slip through unvalidated regardless of which token it targets.
|
|
||||||
//
|
|
||||||
// This does not replicate notes.Edit's uniqueness/CRLF/conflict-marker
|
|
||||||
// checks — if old_str is absent or ambiguous, this preview may not match
|
|
||||||
// what Edit ultimately does, but Edit independently and correctly rejects
|
|
||||||
// those cases at write time regardless of what this function decides, so
|
|
||||||
// the only thing riding on this preview's accuracy is the envelope check
|
|
||||||
// itself, not write safety.
|
|
||||||
// checkClaimEnvelope previews what old_str->new_str would produce and
|
|
||||||
// validates the resulting claim envelope before the real edit runs
|
|
||||||
// (hyperguild#123). Seven review rounds closed every plausible bypass in the
|
|
||||||
// fence-detection family (field-name substring, block-containment,
|
|
||||||
// fence-stripping, CRLF, UTF-8 BOM) and the first instance of a
|
|
||||||
// value-extraction gap (a folded YAML block scalar hiding a value behind an
|
|
||||||
// empty inline colon).
|
|
||||||
//
|
|
||||||
// Known, accepted v1 gap: frontmatterScalars's block-scalar fold is a
|
|
||||||
// best-effort approximation, not a YAML parser — a blank line interrupting a
|
|
||||||
// folded continuation, or other contrived multi-line YAML shapes not yet
|
|
||||||
// probed (block-scalar indicators `|`/`>`, inline comments on a key line,
|
|
||||||
// quoted multi-line values), can still hide an invalid value from
|
|
||||||
// ValidateEnvelope. Accepted deliberately rather than chased indefinitely:
|
|
||||||
// every bypass found so far requires the caller to already hold brain_edit
|
|
||||||
// write access and to deliberately construct a specific malformed-YAML shape
|
|
||||||
// with knowledge of this exact gap — a narrow, low-likelihood threat next to
|
|
||||||
// the diminishing returns of another incremental patch round. The real fix
|
|
||||||
// (a shared, tested, encoding-normalized frontmatter parser used by all
|
|
||||||
// three independent implementations in this codebase) is tracked separately
|
|
||||||
// as hyperguild#138, not bolted on here piece by piece.
|
|
||||||
func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error {
|
|
||||||
if oldStr == "" {
|
|
||||||
// hyperguild#115: strings.Replace treats "" as matching position 0,
|
|
||||||
// which would make an empty old_str look like an edit that inserts
|
|
||||||
// text before the fence — a degenerate-input case that belongs to
|
|
||||||
// notes.Edit's own "old_str is empty" refusal, not to the envelope
|
|
||||||
// check.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
before, err := notes.Read(brainDir, path)
|
|
||||||
if err != nil {
|
|
||||||
// Let notes.Edit itself produce the real error (missing file, etc.)
|
|
||||||
// — this function's job is the envelope, not read failures.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// No gate on where old_str falls relative to the frontmatter fence:
|
|
||||||
// round 2 found that requiring old_str to be fully contained in the
|
|
||||||
// extracted block was itself bypassable by an old_str disambiguated
|
|
||||||
// across the closing "---" into the body. Simpler and bypass-proof:
|
|
||||||
// always compute what the edit would produce and validate that
|
|
||||||
// resulting state's envelope. A body-only edit leaves the frontmatter
|
|
||||||
// (and thus the validation outcome) unchanged, so this is a no-op for
|
|
||||||
// the common case and fail-closed for every case that touches the
|
|
||||||
// envelope, regardless of how old_str is phrased.
|
|
||||||
_, hadFence := frontmatterBlock(string(before))
|
|
||||||
after := strings.Replace(string(before), oldStr, newStr, 1)
|
|
||||||
_, hasFence := frontmatterBlock(after)
|
|
||||||
if hadFence && !hasFence {
|
|
||||||
// Round 3: an edit that drops the closing fence degrades `after`
|
|
||||||
// to "unfenced" — frontmatterScalars then sees an empty map and
|
|
||||||
// ValidateEnvelope({}) is nil by design (absent fields are valid),
|
|
||||||
// so an invalid value would otherwise land with zero error while
|
|
||||||
// also corrupting the note's structure. A previously-fenced note
|
|
||||||
// must stay fenced, full stop.
|
|
||||||
return fmt.Errorf("edit would remove the frontmatter fence")
|
|
||||||
}
|
|
||||||
return claim.ValidateEnvelope(frontmatterScalars(after))
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterBlock returns the raw text between doc's opening and closing
|
|
||||||
// `---` fences, or ok=false if doc has no (properly closed) fence.
|
|
||||||
func frontmatterBlock(doc string) (block string, ok bool) {
|
|
||||||
// hyperguild#138: fence detection itself is frontmatter.Split, shared
|
|
||||||
// with internal/api and internal/frontmatter — the CRLF/BOM guards that
|
|
||||||
// used to live here (added piecemeal across rounds 4 and 5 of #123's
|
|
||||||
// review) are now the shared parser's job, so every caller inherits a
|
|
||||||
// fix instead of needing bug-for-bug parity edits.
|
|
||||||
b, _, ok := frontmatter.Split([]byte(doc))
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
// Split's block keeps the "\n" immediately before the closing fence
|
|
||||||
// (by design — see its own doc comment); this function never included
|
|
||||||
// it. Split guarantees ok=true only when "\n---\n" matched, so this is
|
|
||||||
// always exactly one byte, never ambiguous.
|
|
||||||
return strings.TrimSuffix(string(b), "\n"), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterScalars extracts top-level "key: value" scalar pairs from
|
|
||||||
// doc's leading fence block, for the claim-envelope check only — not a
|
|
||||||
// general frontmatter parser. Indented lines (YAML block-list items,
|
|
||||||
// wrapped values) are skipped rather than misparsed as keys, same fix as
|
|
||||||
// hyperguild#126/#128 in the sibling packages this mirrors.
|
|
||||||
func frontmatterScalars(doc string) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
block, ok := frontmatterBlock(doc)
|
|
||||||
if !ok {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
lines := strings.Split(block, "\n")
|
|
||||||
for i := 0; i < len(lines); i++ {
|
|
||||||
line := lines[i]
|
|
||||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, val, ok := strings.Cut(line, ":")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val = strings.TrimSpace(val)
|
|
||||||
if val == "" {
|
|
||||||
// Round 6: a colon with nothing after it is ambiguous — it can
|
|
||||||
// be a genuinely empty/omitted value, or the start of a YAML
|
|
||||||
// block scalar whose real value lives on the indented
|
|
||||||
// continuation line(s) that follow (e.g. "half_life:\n
|
|
||||||
// durable"). Treating the latter as absent let an edit smuggle
|
|
||||||
// an invalid value straight past ValidateEnvelope: the field
|
|
||||||
// read as unset while the disk actually carried a value. Fold
|
|
||||||
// the continuation in as a best-effort join so oneOf() still
|
|
||||||
// sees it. A genuinely empty value (no indented line follows)
|
|
||||||
// still folds to "", preserving present()'s existing
|
|
||||||
// placeholder-not-assertion contract.
|
|
||||||
var cont []string
|
|
||||||
for i+1 < len(lines) {
|
|
||||||
next := lines[i+1]
|
|
||||||
if next == "" || (next[0] != ' ' && next[0] != '\t') {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cont = append(cont, strings.TrimSpace(next))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
val = strings.Join(cont, " ")
|
|
||||||
}
|
|
||||||
out[strings.TrimSpace(key)] = val
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,271 +66,8 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
|||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// Only the matched bytes change in the body; the one deliberate addition
|
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
|
||||||
// is the editor's provenance stamp (hyperguild#145 — no principal here,
|
"only the matched bytes may change")
|
||||||
// so origin: unknown).
|
|
||||||
assert.Equal(t, "---\ntitle: 'n'\norigin: unknown\n---\n\nalpha\nBETA\n", string(got),
|
|
||||||
"only the matched bytes and the provenance stamp may change")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
|
|
||||||
// hyperguild#115: a session reported "old_str is empty" with an old_str
|
|
||||||
// that, on their end, was never empty. The server-side path was
|
|
||||||
// unreachable to reproduce (verified separately: a fresh brain_edit call
|
|
||||||
// with real content round-trips cleanly), which points at something
|
|
||||||
// between a specific client and this call losing the field. The fix this
|
|
||||||
// issue actually asked for isn't a behavior change — old_str=="" must
|
|
||||||
// still refuse — it's that the refusal should say how many raw argument
|
|
||||||
// bytes it received, so "client sent empty" is distinguishable from
|
|
||||||
// "server received empty after some hop lost it" without a second
|
|
||||||
// session's worth of guessing.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "", "new_str": "BETA",
|
|
||||||
})
|
|
||||||
errMsg, ok := resp["error"].(map[string]any)["message"].(string)
|
|
||||||
require.True(t, ok, "%v", resp)
|
|
||||||
assert.Contains(t, errMsg, "old_str is empty")
|
|
||||||
assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplace(t *testing.T) {
|
|
||||||
// hyperguild#123: brain_write/brain_update both validate the claim
|
|
||||||
// envelope (half_life/applies/claim_provenance) before writing —
|
|
||||||
// brain_edit didn't, even though it's the one live, model-facing path
|
|
||||||
// that can rewrite an existing envelope line directly via raw string
|
|
||||||
// replace, no parsing involved. "same rules when a note changes as when
|
|
||||||
// it's born" (hyperguild#111's own framing) applies here too.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "claim_provenance: asserted", "new_str": "claim_provenance: guessed",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelope(t *testing.T) {
|
|
||||||
// Found in review: the original gate (touchesClaimEnvelope) checked
|
|
||||||
// whether old_str/new_str mention the FIELD NAME — but a minimal diff
|
|
||||||
// (the natural shape of any real edit, model-generated or human) can
|
|
||||||
// target just the VALUE token and never mention the field name at all.
|
|
||||||
// "asserted" -> "guessed" on a claim_provenance line walked straight
|
|
||||||
// past the field-name substring check and wrote an invalid enum value
|
|
||||||
// with no error — the exact bypass #123 was supposed to close, closed
|
|
||||||
// in name only.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAnOldStrThatCrossesTheClosingFence(t *testing.T) {
|
|
||||||
// Found in round-2 review: the position-based gate required old_str to
|
|
||||||
// be fully CONTAINED in the extracted frontmatter block (which excludes
|
|
||||||
// both "---" fence lines). An old_str disambiguated by extending past
|
|
||||||
// the closing fence into the body — a natural choice, since notes.Edit
|
|
||||||
// requires old_str to be unique in the file — fails that containment
|
|
||||||
// test, so checkClaimEnvelope skips validation entirely while
|
|
||||||
// notes.Edit still applies the replace verbatim. Same invalid-enum
|
|
||||||
// outcome as the original two bypasses, reached a third way.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md",
|
|
||||||
"old_str": "asserted\n---\n\nbody",
|
|
||||||
"new_str": "guessed\n---\n\nbody",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAnEditThatRemovesTheClosingFence(t *testing.T) {
|
|
||||||
// Found in round-3 review: checkClaimEnvelope always validates the
|
|
||||||
// resulting frontmatter of `after` — but an edit whose new_str drops
|
|
||||||
// the closing "---" degrades `after` to "unfenced" in the same call.
|
|
||||||
// frontmatterBlock(after) then returns ok=false, frontmatterScalars
|
|
||||||
// returns an empty map, and claim.ValidateEnvelope({}) is nil by
|
|
||||||
// design (absent fields are valid) — so an invalid enum value lands on
|
|
||||||
// disk with zero error, and the note is left permanently unfenced.
|
|
||||||
// Structural fix: a note that had a valid fence before the edit must
|
|
||||||
// still have one after, or the edit is rejected outright.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md",
|
|
||||||
"old_str": "claim_provenance: asserted\n---\n",
|
|
||||||
"new_str": "claim_provenance: not-a-real-value\n",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted\n---\n", "rejected edit must leave the note byte-identical, fence intact")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNote(t *testing.T) {
|
|
||||||
// Found in round-4 review: frontmatterBlock's fence detection matches
|
|
||||||
// the literal byte sequence "---\n" / "\n---\n". A note whose fence
|
|
||||||
// lines use CRLF ("---\r\n") fails strings.HasPrefix(doc, "---\n") on
|
|
||||||
// the BEFORE read, so hadFence is false — not because the fence is
|
|
||||||
// missing, but because \r sits in front of every \n. hadFence&&!hasFence
|
|
||||||
// can then never fire, frontmatterScalars falls back to an empty map,
|
|
||||||
// and claim.ValidateEnvelope({}) is nil by design. No adversarial
|
|
||||||
// old_str/new_str crafting needed: encoding alone silently disables the
|
|
||||||
// whole envelope check for this note. Zero CRLF-fenced notes exist in
|
|
||||||
// the live corpus today, but nothing in the code prevents one.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\r\ntitle: 'n'\r\nhalf_life: durable\r\napplies: universal\r\nclaim_provenance: asserted\r\n---\r\n\r\nbody\r\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAnInvalidEnvelopeOnABOMPrefixedNote(t *testing.T) {
|
|
||||||
// Found in round-5 review: the same "encoding property empties
|
|
||||||
// frontmatterScalars's map" mechanism as the CRLF bypass, one layer
|
|
||||||
// earlier — a leading UTF-8 BOM (EF BB BF) defeats
|
|
||||||
// strings.HasPrefix(doc, "---\n") exactly like CRLF did, before that
|
|
||||||
// prefix is even reached. Notepad's default "UTF-8" save and
|
|
||||||
// PowerShell's pre-v6 Out-File/Set-Content both emit a BOM, so this is
|
|
||||||
// a live, plausible authoring path, not an exotic one — same severity
|
|
||||||
// and same one-line cost as the CRLF fix, fixed the same way rather
|
|
||||||
// than deferred to hyperguild#138.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("\xef\xbb\xbf---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RejectsAFoldedScalarThatHidesAnInvalidValue(t *testing.T) {
|
|
||||||
// Found in round-6 review: a different mechanism from the encoding
|
|
||||||
// family (rounds 1-6 were all about fence detection; this is the field
|
|
||||||
// extractor). old_str="half_life: durable", new_str="half_life:\n
|
|
||||||
// garbage-value" turns the field into a YAML block-scalar shape: the
|
|
||||||
// top-level line is now "half_life:" with an empty inline value, and
|
|
||||||
// the real value lives on the indented continuation line that follows.
|
|
||||||
// frontmatterScalars's indentation guard skips that continuation line
|
|
||||||
// outright, so the field reads as present-with-empty-value —
|
|
||||||
// present()'s own contract then treats that as absent (a placeholder,
|
|
||||||
// not an assertion) — and claim.ValidateEnvelope never sees the
|
|
||||||
// invalid value at all. No encoding quirk needed: one ordinary
|
|
||||||
// brain_edit call smuggles an out-of-enum value straight past the
|
|
||||||
// check the field was supposed to be validated by.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md",
|
|
||||||
"old_str": "half_life: durable",
|
|
||||||
"new_str": "half_life:\n garbage-value",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "half_life: durable", "rejected edit must leave the note byte-identical")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_AllowsAValidClaimEnvelopeEdit(t *testing.T) {
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "half_life: durable", "new_str": "half_life: seasonal",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "half_life: seasonal")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_UnrelatedEditsSkipTheEnvelopeCheckEntirely(t *testing.T) {
|
|
||||||
// The gate only engages when old_str/new_str actually mention an
|
|
||||||
// envelope field name — an ordinary body edit on a note that happens to
|
|
||||||
// carry a valid envelope must not pay for (or be affected by) a check
|
|
||||||
// that has nothing to do with it.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
|
|
||||||
require.NoError(t, os.WriteFile(note,
|
|
||||||
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nold body\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "old body", "new_str": "new body",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "new body")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_GovernanceFilesAreNeverEnvelopeChecked(t *testing.T) {
|
|
||||||
// CLAUDE.md has no claim-envelope concept at all — the check must be
|
|
||||||
// scoped to notes (ScopeNotes), never applied on the governance path.
|
|
||||||
srv, _, _, _ := govServer(t)
|
|
||||||
|
|
||||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "half_life: durable",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
@@ -17,12 +16,6 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
|
|||||||
out := make([]wiki.Page, 0, len(rawPages))
|
out := make([]wiki.Page, 0, len(rawPages))
|
||||||
var warnings []string
|
var warnings []string
|
||||||
for _, rp := range rawPages {
|
for _, rp := range rawPages {
|
||||||
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
|
||||||
// (PR #147 review). Only a plain lower-case name is a page type.
|
|
||||||
if !validPageType.MatchString(rp.Type) {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slug := computeSlug(rp, sourceSlug)
|
slug := computeSlug(rp, sourceSlug)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
||||||
@@ -33,8 +26,6 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
|
|||||||
return out, warnings
|
return out, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||||
if rp.Type == "source" {
|
if rp.Type == "source" {
|
||||||
return sourceSlug
|
return sourceSlug
|
||||||
@@ -85,9 +76,6 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||||
if rp.SourceHash != "" {
|
|
||||||
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
|
|
||||||
}
|
|
||||||
if rp.Source != "" {
|
if rp.Source != "" {
|
||||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||||
}
|
}
|
||||||
@@ -123,9 +111,5 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func yamlScalar(s string) string {
|
func yamlScalar(s string) string {
|
||||||
// A raw newline or CR would end the scalar for every line-oriented
|
|
||||||
// frontmatter reader and start a new key (PR #147 review: a title of
|
|
||||||
// "x\norigin: static" forged provenance). Fold all line breaks to spaces.
|
|
||||||
s = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s)
|
|
||||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ type RawPage struct {
|
|||||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||||
// might emit. Only meaningful for Type == "source".
|
// might emit. Only meaningful for Type == "source".
|
||||||
// SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
|
|
||||||
// so the next ingest of the same source can tell "unchanged" from "edited"
|
|
||||||
// without re-running extraction (#105).
|
|
||||||
SourceHash string `json:"-"`
|
|
||||||
Source string `json:"-"`
|
Source string `json:"-"`
|
||||||
Author string `json:"-"`
|
Author string `json:"-"`
|
||||||
Published string `json:"-"`
|
Published string `json:"-"`
|
||||||
@@ -116,11 +112,6 @@ type sourceMeta struct {
|
|||||||
Source string
|
Source string
|
||||||
Author string
|
Author string
|
||||||
Published string
|
Published string
|
||||||
// BodyHash is sha256 of the content with any leading frontmatter removed.
|
|
||||||
// Body-only on purpose: a re-crawl that stamps a new fetch date has not
|
|
||||||
// changed the source, and treating that as drift would re-extract the
|
|
||||||
// whole corpus every time a crawler ran (#105).
|
|
||||||
BodyHash string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseContentFrontmatter extracts source/author/published from a leading
|
// parseContentFrontmatter extracts source/author/published from a leading
|
||||||
@@ -163,7 +154,6 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
|||||||
if pages[i].Type != "source" {
|
if pages[i].Type != "source" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pages[i].SourceHash = meta.BodyHash
|
|
||||||
pages[i].Source = meta.Source
|
pages[i].Source = meta.Source
|
||||||
pages[i].Author = meta.Author
|
pages[i].Author = meta.Author
|
||||||
pages[i].Published = meta.Published
|
pages[i].Published = meta.Published
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,11 +26,6 @@ type Config struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Pages []string // relative paths written (or would-be written in dry-run)
|
Pages []string // relative paths written (or would-be written in dry-run)
|
||||||
Warnings []string
|
Warnings []string
|
||||||
// Skipped reports that the source was byte-identical to the last ingest,
|
|
||||||
// so no extraction ran and nothing was written. Reported rather than
|
|
||||||
// silent: a run that does nothing must say so, or "the pipeline is fine"
|
|
||||||
// and "the pipeline stopped working" look the same (#105, #107).
|
|
||||||
Skipped bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
||||||
@@ -51,32 +43,13 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
|
||||||
// pass over every chunk to produce a result that merges to nothing (#105).
|
|
||||||
// The merge is already idempotent (#103), so this is cost and signal, not
|
|
||||||
// correctness — which is why it is safe for it to be an early return.
|
|
||||||
bodyHash := hashBody(content)
|
|
||||||
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
|
|
||||||
return Result{Skipped: true, Warnings: []string{
|
|
||||||
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
|
|
||||||
}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The source's own page path is deterministic (sourceSlug, computed above)
|
|
||||||
// even before extraction runs, unlike a concept/entity page's path, which
|
|
||||||
// only exists once the model has produced a title. So page-aware
|
|
||||||
// extraction (#106) is scoped to the source page: it's the one page every
|
|
||||||
// chunk's prompt can be given in advance.
|
|
||||||
existingPage := readSourcePage(brainDir, sourceSlug)
|
|
||||||
|
|
||||||
chunks := Chunk(content, cfg.ChunkSize)
|
chunks := Chunk(content, cfg.ChunkSize)
|
||||||
|
|
||||||
var allRaw []RawPage
|
var allRaw []RawPage
|
||||||
var allWarnings []string
|
var allWarnings []string
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
userPrompt := BuildPrompt(schema, source, chunk, inventory, existingPage)
|
userPrompt := BuildPrompt(schema, source, chunk, inventory)
|
||||||
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
|
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("LLM call: %w", err)
|
return Result{}, fmt.Errorf("LLM call: %w", err)
|
||||||
@@ -86,21 +59,15 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
allWarnings = append(allWarnings, warnings...)
|
allWarnings = append(allWarnings, warnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := parseContentFrontmatter(content)
|
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
||||||
meta.BodyHash = bodyHash
|
|
||||||
applySourceMeta(allRaw, meta)
|
|
||||||
|
|
||||||
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||||
// step. Use this when the caller has already produced the structured RawPage data
|
// step. Use this when the caller has already produced the structured RawPage data
|
||||||
// (e.g. from a more capable model or manual curation).
|
// (e.g. from a more capable model or manual curation).
|
||||||
//
|
func RunRaw(brainDir, source string, rawPages []RawPage, dryRun bool) (Result, error) {
|
||||||
// Every page written is stamped with provenance.FromContext(ctx)
|
|
||||||
// (hyperguild#145); a caller that knows the principal attaches it with
|
|
||||||
// provenance.WithStamp, everyone else stamps origin: unknown.
|
|
||||||
func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dryRun bool) (Result, error) {
|
|
||||||
inventory, err := wiki.LoadInventory(brainDir)
|
inventory, err := wiki.LoadInventory(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("load inventory: %w", err)
|
return Result{}, fmt.Errorf("load inventory: %w", err)
|
||||||
@@ -109,11 +76,11 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr
|
|||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
return buildAndWrite(rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
||||||
func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
||||||
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
|
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
|
||||||
warnings = append(warnings, buildWarnings...)
|
warnings = append(warnings, buildWarnings...)
|
||||||
resolved := Resolve(pages, inventory)
|
resolved := Resolve(pages, inventory)
|
||||||
@@ -129,25 +96,11 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
content, conflicts, err := reviseOrCreate(dest, page)
|
content, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
for _, heading := range conflicts {
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
// Reported, not resolved. The on-disk version stands; whether
|
|
||||||
// the disagreement is a genuine contradiction is the
|
|
||||||
// Judge-gated enrich pass's call, not the write path's.
|
|
||||||
warnings = append(warnings, fmt.Sprintf(
|
|
||||||
"%s: source now disagrees on section %q — on-disk version kept",
|
|
||||||
page.Path, heading))
|
|
||||||
}
|
|
||||||
// Stamped as the last step, after merge/revise, so a page that
|
|
||||||
// was merged into is re-attributed to this ingest too.
|
|
||||||
stamped, err := provenance.Apply(content, provenance.FromContext(ctx))
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil {
|
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,51 +119,6 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
|
|||||||
return Result{Pages: written, Warnings: warnings}, nil
|
return Result{Pages: written, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashBody returns sha256 of content with any leading frontmatter block
|
|
||||||
// removed, or "" for empty content.
|
|
||||||
//
|
|
||||||
// Body-only on purpose: 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 (#105).
|
|
||||||
func hashBody(content string) string {
|
|
||||||
body := content
|
|
||||||
if strings.HasPrefix(body, "---\n") {
|
|
||||||
if _, rest, found := strings.Cut(body[4:], "\n---"); found {
|
|
||||||
body = strings.TrimPrefix(rest, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(body) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(body))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// readSourcePage returns the full on-disk content of the source page at
|
|
||||||
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
|
|
||||||
func readSourcePage(brainDir, sourceSlug string) string {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedSourceHash reads source_hash from the source page a previous ingest
|
|
||||||
// wrote, or "" when there is no such page or no such field.
|
|
||||||
//
|
|
||||||
// Absence always means "extract". That is the fail-safe direction: a missed
|
|
||||||
// skip costs one extraction, while a wrong skip loses an update silently — and
|
|
||||||
// silence is the failure mode this repo keeps paying for.
|
|
||||||
func storedSourceHash(brainDir, sourceSlug string) string {
|
|
||||||
for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") {
|
|
||||||
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// reviseOrCreate returns the bytes to write for one extracted page: merged into
|
// reviseOrCreate returns the bytes to write for one extracted page: merged into
|
||||||
// whatever is already on disk, or the page as-is when the path is new.
|
// whatever is already on disk, or the page as-is when the path is new.
|
||||||
//
|
//
|
||||||
@@ -230,16 +138,15 @@ func storedSourceHash(brainDir, sourceSlug string) string {
|
|||||||
// its first-ingest date on a revised page. Left deliberately — the alternative
|
// its first-ingest date on a revised page. Left deliberately — the alternative
|
||||||
// is this function editing frontmatter it does not own, and no lint reads that
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
// field today.
|
// field today.
|
||||||
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
|
||||||
existing, err := os.ReadFile(dest)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
return page.Content, nil, nil
|
return page.Content, nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
|
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
|
||||||
return merged.Content, conflicts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -196,7 +195,7 @@ A book about shaping product work.
|
|||||||
Read this before the next planning cycle.
|
Read this before the next planning cycle.
|
||||||
`), 0o644))
|
`), 0o644))
|
||||||
|
|
||||||
_, err := RunRaw(context.Background(), dir, "shape-up", []RawPage{{
|
_, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
||||||
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
||||||
}}, false)
|
}}, false)
|
||||||
@@ -221,12 +220,12 @@ func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
|||||||
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, err := RunRaw(context.Background(), dir, "shape-up", raw, false)
|
_, err := RunRaw(dir, "shape-up", raw, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = RunRaw(context.Background(), dir, "shape-up", raw, false)
|
_, err = RunRaw(dir, "shape-up", raw, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -234,195 +233,3 @@ func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
|||||||
assert.Equal(t, string(first), string(second),
|
assert.Equal(t, string(first), string(second),
|
||||||
"re-ingesting unchanged content must leave the page byte-identical")
|
"re-ingesting unchanged content must leave the page byte-identical")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
|
|
||||||
|
|
||||||
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
|
|
||||||
// #103 made a re-ingest harmless (byte-identical merge). It is still not
|
|
||||||
// free: the full LLM extraction runs over every chunk to produce a result
|
|
||||||
// that merges to nothing. This is the cost half.
|
|
||||||
dir := t.TempDir()
|
|
||||||
var calls int
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
||||||
calls++
|
|
||||||
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
|
|
||||||
|
|
||||||
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, 1, calls, "first ingest must extract")
|
|
||||||
assert.False(t, first.Skipped)
|
|
||||||
|
|
||||||
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
|
|
||||||
assert.True(t, second.Skipped, "the skip must be reported, not silent")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
var calls int
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
||||||
calls++
|
|
||||||
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, calls, "changed content must be re-extracted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- #106: page-aware extraction — the model sees the page it is revising --
|
|
||||||
|
|
||||||
func TestRun_ExistingSourcePageIsIncludedInTheExtractionPrompt(t *testing.T) {
|
|
||||||
// #103 made the merge asymmetric (on-disk wins); #106 is the other half —
|
|
||||||
// the extraction itself gets to see what's already there instead of
|
|
||||||
// producing a page blind and letting the merge reconcile it afterwards.
|
|
||||||
// This only proves the existing page reaches the prompt; the byte-identical
|
|
||||||
// guarantee for an echoed-back revision is a separate test below.
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
||||||
}
|
|
||||||
|
|
||||||
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
|
||||||
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
|
||||||
|
|
||||||
var gotPrompt string
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
|
||||||
gotPrompt = user
|
|
||||||
return mustJSON([]RawPage{{
|
|
||||||
Title: "Shape Up", Type: "source", Subtype: "book",
|
|
||||||
Content: "## Summary\n\nA book about shaping product work.\n",
|
|
||||||
}}), nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Deliberately does not mention "Appetite beats estimation." — that phrase
|
|
||||||
// can only reach the prompt via the existing on-disk page, not the source
|
|
||||||
// content, so its presence proves the page was threaded through.
|
|
||||||
content := "A short editorial update to the book's listing. No new claims here."
|
|
||||||
_, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, gotPrompt, "Appetite beats estimation.",
|
|
||||||
"the existing page's content must be visible to the extraction prompt")
|
|
||||||
assert.Contains(t, gotPrompt, "revis", "the prompt must ask for a revision, not a fresh page")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_NewSourceHasNoExistingPageInThePrompt(t *testing.T) {
|
|
||||||
// The other side of the same behavior: a source with no page on disk yet
|
|
||||||
// must not get an "existing page" section injected into its prompt at all.
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
||||||
}
|
|
||||||
|
|
||||||
var gotPrompt string
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
|
||||||
gotPrompt = user
|
|
||||||
return mustJSON([]RawPage{{
|
|
||||||
Title: "New Thing", Type: "source", Subtype: "article",
|
|
||||||
Content: "## Summary\n\nBrand new.\n",
|
|
||||||
}}), nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := Run(context.Background(), cfg, dir, "Brand new content.", "new-thing", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotContains(t, gotPrompt, "Current page",
|
|
||||||
"a source with no page on disk yet must not get a revision section in its prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_RevisionEchoingExistingPageStaysByteIdentical(t *testing.T) {
|
|
||||||
// The issue's explicit risk: a model given the current page will sometimes
|
|
||||||
// echo it back verbatim. That must not duplicate content or otherwise
|
|
||||||
// perturb the page — the deterministic merge is still the backstop.
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
||||||
}
|
|
||||||
|
|
||||||
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
|
||||||
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
|
||||||
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
||||||
return mustJSON([]RawPage{{
|
|
||||||
Title: "Shape Up", Type: "source", Subtype: "book",
|
|
||||||
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
|
||||||
}}), nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
content := "A short editorial update to the book's listing. No new claims here."
|
|
||||||
result, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, result.Skipped)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(page)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
|
|
||||||
"\nsource_hash: " + hashBody(content) +
|
|
||||||
// Every page an ingest writes carries provenance (hyperguild#145);
|
|
||||||
// no stamp in ctx here, so origin: unknown.
|
|
||||||
"\norigin: unknown\n---\n\n" +
|
|
||||||
"## Summary\n\nA book about shaping product work.\n\n" +
|
|
||||||
"## Key Claims\n\n- Appetite beats estimation.\n"
|
|
||||||
assert.Equal(t, want, string(got),
|
|
||||||
"a revision pass that echoes the existing page back must not duplicate or alter its content")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
|
||||||
// The hash covers the body, so re-fetching a page whose metadata moved
|
|
||||||
// (a re-crawl stamping a new date) is not treated as the source changing.
|
|
||||||
dir := t.TempDir()
|
|
||||||
var calls int
|
|
||||||
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
||||||
calls++
|
|
||||||
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, calls, "only the body counts as the source")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) {
|
|
||||||
// The on-disk version still wins — that is what makes unattended ingest
|
|
||||||
// safe. But the run must say that the source now claims something else,
|
|
||||||
// or a changed source is indistinguishable from an unchanged one.
|
|
||||||
dir := t.TempDir()
|
|
||||||
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(`---
|
|
||||||
title: 'Shape Up'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is fixed, scope is variable.
|
|
||||||
`), 0o644))
|
|
||||||
|
|
||||||
res, err := RunRaw(context.Background(), dir, "shape-up", []RawPage{{
|
|
||||||
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product",
|
|
||||||
Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n",
|
|
||||||
}}, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(page)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins")
|
|
||||||
|
|
||||||
joined := strings.Join(res.Warnings, " ")
|
|
||||||
assert.Contains(t, joined, "Summary", "the disagreeing section must be named")
|
|
||||||
assert.Contains(t, joined, "shape-up", "and the page it happened on")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,18 +22,8 @@ Each element must have exactly these fields:
|
|||||||
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
|
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
|
||||||
Only link to pages listed in the inventory or pages you are creating in this response.`
|
Only link to pages listed in the inventory or pages you are creating in this response.`
|
||||||
|
|
||||||
// BuildPrompt constructs the user prompt for a single chunk. existingPage is
|
// BuildPrompt constructs the user prompt for a single chunk.
|
||||||
// the full current content (frontmatter + body) of the source's own wiki page
|
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry) string {
|
||||||
// when one already exists on disk, or "" for a source being ingested for the
|
|
||||||
// first time. When non-empty, the model is asked to revise that page rather
|
|
||||||
// than write a fresh one — see hyperguild#106. The deterministic merge in
|
|
||||||
// wiki.Merge remains the backstop regardless of what the model returns: an
|
|
||||||
// on-disk section the model doesn't touch survives untouched, and a section
|
|
||||||
// it does touch only takes effect where Merge's strategy allows the fresh
|
|
||||||
// side to change anything (bullet union, append) — never for an ordinary
|
|
||||||
// section, so the model proposing a revision is never how a page gets
|
|
||||||
// overwritten.
|
|
||||||
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry, existingPage string) string {
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "Today's date is %s.\n\n", time.Now().UTC().Format("2006-01-02"))
|
fmt.Fprintf(&sb, "Today's date is %s.\n\n", time.Now().UTC().Format("2006-01-02"))
|
||||||
@@ -59,18 +49,6 @@ func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]w
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingPage != "" {
|
|
||||||
sb.WriteString("## Current page for this source — revise it, don't restart\n\n")
|
|
||||||
sb.WriteString("This source has already been ingested and has the wiki page shown below.\n")
|
|
||||||
sb.WriteString("You are revising that page, not writing a fresh one:\n")
|
|
||||||
sb.WriteString(" - Add sections or claims the source below supports that this page doesn't have yet.\n")
|
|
||||||
sb.WriteString(" - Strengthen a claim where the source gives more detail, evidence, or nuance than what's already there.\n")
|
|
||||||
sb.WriteString(" - Leave unchanged whatever the source doesn't affect — reuse the existing wording verbatim rather than paraphrasing it, so an unchanged claim is recognized as unchanged.\n")
|
|
||||||
sb.WriteString(" - A merge step keeps this page's on-disk content for any section you don't return, so it's fine to omit a section you aren't revising.\n\n")
|
|
||||||
sb.WriteString(existingPage)
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("## Non-negotiable rules\n\n")
|
sb.WriteString("## Non-negotiable rules\n\n")
|
||||||
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
|
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
|
||||||
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
|
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
// Package provenance stamps server-owned write provenance (hyperguild#145)
|
|
||||||
// into a brain note's frontmatter: origin (static | jwt | unknown) and
|
|
||||||
// written_by (the authenticated principal), so a reader can discount what a
|
|
||||||
// connector session wrote after reading an untrusted page.
|
|
||||||
//
|
|
||||||
// The control is a post-condition applied by every write path a model can
|
|
||||||
// reach. Two review rounds on PR #147 showed that deciding line by line which
|
|
||||||
// lines are provenance loses to YAML's context-dependent features — an alias
|
|
||||||
// key resolves only against an anchor elsewhere, and a `...` line ends the
|
|
||||||
// document before the stamp. So Apply does not try to recognise provenance.
|
|
||||||
// It keeps only frontmatter it can prove is plain:
|
|
||||||
//
|
|
||||||
// - a top-level line `key: value` whose key is a bare identifier,
|
|
||||||
// - together with that key's continuation lines (indented, or `- ` items
|
|
||||||
// after an empty value),
|
|
||||||
// - where the group decodes ON ITS OWN, with yaml.v3, to a mapping with
|
|
||||||
// exactly that one key,
|
|
||||||
// - and the key is not origin/written_by and has not been seen before.
|
|
||||||
//
|
|
||||||
// Everything else — aliases, anchors used as keys, document markers, complex
|
|
||||||
// keys, merge keys, flow maps, tags, quoted keys, duplicates — is dropped.
|
|
||||||
// Then the stamp is appended and the whole result is decoded by yaml.v3 as a
|
|
||||||
// backstop: if it does not decode to exactly the stamped values, Apply fails
|
|
||||||
// and the caller refuses the write.
|
|
||||||
package provenance
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stamp is the provenance a write carries. An empty Origin means "unknown".
|
|
||||||
type Stamp struct {
|
|
||||||
Origin string
|
|
||||||
WrittenBy string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ctxKey struct{}
|
|
||||||
|
|
||||||
// WithStamp attaches the caller's provenance to ctx for code paths (the ingest
|
|
||||||
// pipeline) that write notes far from where the principal is known.
|
|
||||||
func WithStamp(ctx context.Context, s Stamp) context.Context {
|
|
||||||
return context.WithValue(ctx, ctxKey{}, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromContext returns the stamp in ctx, or the unknown stamp.
|
|
||||||
func FromContext(ctx context.Context) Stamp {
|
|
||||||
if s, ok := ctx.Value(ctxKey{}).(Stamp); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return Stamp{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrUnverifiable means the stamped note would not read back as stamped.
|
|
||||||
var ErrUnverifiable = errors.New("provenance: stamped frontmatter does not decode to the stamp")
|
|
||||||
|
|
||||||
var (
|
|
||||||
keyLine = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_.-]*)[ ]*:(?:[ ]|$)(.*)$`)
|
|
||||||
bareScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`)
|
|
||||||
reserved = map[string]bool{"origin": true, "written_by": true}
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s Stamp) origin() string {
|
|
||||||
if s.Origin == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return s.Origin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply returns content with its frontmatter reduced to plain groups and the
|
|
||||||
// stamp appended. Content without frontmatter gains a block.
|
|
||||||
func Apply(content string, s Stamp) (string, error) {
|
|
||||||
stamp := "origin: " + scalar(s.origin()) + "\n"
|
|
||||||
if s.WrittenBy != "" {
|
|
||||||
stamp += "written_by: " + scalar(s.WrittenBy) + "\n"
|
|
||||||
}
|
|
||||||
content = foldFrontmatterCR(content)
|
|
||||||
block, rest, ok := sharedfm.Split([]byte(content))
|
|
||||||
var out string
|
|
||||||
switch {
|
|
||||||
case !ok && strings.HasPrefix(strings.ReplaceAll(content, "\r\n", "\n"), "---\n"):
|
|
||||||
// A fence was opened but cannot be closed (e.g. a "---x" line).
|
|
||||||
// Prepending a second block would demote the original frontmatter
|
|
||||||
// into the body, so refuse instead (PR #147 review round 3).
|
|
||||||
return "", fmt.Errorf("%w: unclosed frontmatter fence", ErrUnverifiable)
|
|
||||||
case !ok:
|
|
||||||
out = "---\n" + stamp + "---\n" + content
|
|
||||||
default:
|
|
||||||
out = "---\n" + plainGroups(string(block)) + stamp + "---\n" + strings.TrimPrefix(string(rest), "\n")
|
|
||||||
}
|
|
||||||
if err := verify(out, s); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyFile applies the stamp to the note at absPath in place.
|
|
||||||
func ApplyFile(absPath string, s Stamp) error {
|
|
||||||
b, err := os.ReadFile(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read for provenance: %w", err)
|
|
||||||
}
|
|
||||||
out, err := Apply(string(b), s)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if out == string(b) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write provenance: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// plainGroups keeps the provably plain key groups of a frontmatter block.
|
|
||||||
func plainGroups(block string) string {
|
|
||||||
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var b strings.Builder
|
|
||||||
for i := 0; i < len(lines); {
|
|
||||||
line := lines[i]
|
|
||||||
m := keyLine.FindStringSubmatch(line)
|
|
||||||
if m == nil {
|
|
||||||
i++ // not a plain key line: dropped, as is anything it would govern
|
|
||||||
for i < len(lines) && isContinuation(lines[i], "") {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, value := m[1], strings.TrimSpace(m[2])
|
|
||||||
j := i + 1
|
|
||||||
for j < len(lines) && isContinuation(lines[j], value) {
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
group := strings.Join(lines[i:j], "\n") + "\n"
|
|
||||||
lk := strings.ToLower(key)
|
|
||||||
if !reserved[lk] && !seen[lk] && decodesToOnly(group, key) {
|
|
||||||
seen[lk] = true
|
|
||||||
b.WriteString(group)
|
|
||||||
}
|
|
||||||
i = j
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// isContinuation reports whether line belongs to the preceding key: indented
|
|
||||||
// lines always do; a top-level "- " item only follows a key with no value.
|
|
||||||
func isContinuation(line, value string) bool {
|
|
||||||
if line == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if line[0] == ' ' {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return value == "" && strings.HasPrefix(line, "- ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodesToOnly(group, key string) bool {
|
|
||||||
var m map[string]any
|
|
||||||
if err := yaml.Unmarshal([]byte(group), &m); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := m[key]
|
|
||||||
return ok && len(m) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func verify(doc string, s Stamp) error {
|
|
||||||
block, _, ok := sharedfm.Split([]byte(doc))
|
|
||||||
if !ok {
|
|
||||||
return ErrUnverifiable
|
|
||||||
}
|
|
||||||
var m map[string]any
|
|
||||||
if err := yaml.Unmarshal(block, &m); err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrUnverifiable, err)
|
|
||||||
}
|
|
||||||
if m["origin"] != s.origin() {
|
|
||||||
return ErrUnverifiable
|
|
||||||
}
|
|
||||||
if s.WrittenBy == "" {
|
|
||||||
if _, has := m["written_by"]; has {
|
|
||||||
return ErrUnverifiable
|
|
||||||
}
|
|
||||||
} else if m["written_by"] != s.WrittenBy {
|
|
||||||
return ErrUnverifiable
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// foldFrontmatterCR turns CRLF and lone CR inside a leading frontmatter block
|
|
||||||
// into LF, so every reader agrees on where lines end.
|
|
||||||
func foldFrontmatterCR(content string) string {
|
|
||||||
s := strings.ReplaceAll(content, "\r\n", "\n")
|
|
||||||
if !strings.HasPrefix(s, "---\n") {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
end := strings.Index(s[4:], "\n---")
|
|
||||||
if end < 0 {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(s[:4+end], "\r", "\n") + s[4+end:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// scalar renders a bare word as-is only when YAML reads it back as that same
|
|
||||||
// string; anything else (true, 123, null, ~, punctuation, newlines) becomes
|
|
||||||
// a double-quoted string, so a principal name can never break the line or
|
|
||||||
// change type.
|
|
||||||
func scalar(v string) string {
|
|
||||||
if bareScalar.MatchString(v) {
|
|
||||||
var back any
|
|
||||||
if yaml.Unmarshal([]byte(v), &back) == nil {
|
|
||||||
if s, ok := back.(string); ok && s == v {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%q", v)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package provenance
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func fmOf(t *testing.T, doc string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
parts := strings.SplitN(doc, "---\n", 3)
|
|
||||||
require.Len(t, parts, 3, doc)
|
|
||||||
var m map[string]any
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte(parts[1]), &m), parts[1])
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// The whole-document backstop: a stamp that would not read back as written
|
|
||||||
// is refused, never written. A principal with an invalid UTF-8 byte quotes
|
|
||||||
// to "\xff", which YAML decodes as U+00FF, so it cannot round-trip.
|
|
||||||
func TestApplyRefusesAStampThatDoesNotReadBack(t *testing.T) {
|
|
||||||
_, err := Apply("---\ntitle: t\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "bad\xff"})
|
|
||||||
assert.True(t, errors.Is(err, ErrUnverifiable), "got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A principal that YAML would decode as a non-string (true, 123, null) is
|
|
||||||
// quoted, so the stamp reads back as the same string.
|
|
||||||
func TestApplyQuotesPrincipalsYAMLWouldNotReadAsStrings(t *testing.T) {
|
|
||||||
for _, p := range []string{"true", "123", "null", "1e3", "0x1F", "yes", "~"} {
|
|
||||||
out, err := Apply("---\ntitle: t\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: p})
|
|
||||||
require.NoError(t, err, p)
|
|
||||||
assert.Equal(t, p, fmOf(t, out)["written_by"], p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A group that does not decode on its own is dropped; its neighbours stay.
|
|
||||||
func TestApplyDropsGroupsThatDoNotDecodeAlone(t *testing.T) {
|
|
||||||
out, err := Apply("---\nkeep: 1\ntitle: a: b\nalso: 2\n---\nbody\n", Stamp{Origin: "static", WrittenBy: "local-cli"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := fmOf(t, out)
|
|
||||||
assert.Equal(t, 1, m["keep"])
|
|
||||||
assert.Equal(t, 2, m["also"])
|
|
||||||
assert.NotContains(t, m, "title")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyKeepsFirstOfDuplicateKeys(t *testing.T) {
|
|
||||||
out, err := Apply("---\ntitle: first\ntitle: second\nTitle: third\n---\nbody\n", Stamp{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := fmOf(t, out)
|
|
||||||
assert.Equal(t, "first", m["title"])
|
|
||||||
assert.NotContains(t, m, "Title")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyFoldsLoneCRSoItCannotHideALine(t *testing.T) {
|
|
||||||
out, err := Apply("---\ntitle: x\rorigin: static\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotContains(t, out, "\r")
|
|
||||||
assert.Equal(t, "jwt", fmOf(t, out)["origin"])
|
|
||||||
assert.Equal(t, "x", fmOf(t, out)["title"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDropsTabIndentedContinuations(t *testing.T) {
|
|
||||||
out, err := Apply("---\ntitle: x\n\torigin: static\nk: v\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := fmOf(t, out)
|
|
||||||
assert.Equal(t, "jwt", m["origin"])
|
|
||||||
assert.Equal(t, "v", m["k"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content that opens a fence the shared splitter cannot close (e.g. a
|
|
||||||
// "---x" line) is refused rather than given a second, prepended block that
|
|
||||||
// demotes the original frontmatter into the body.
|
|
||||||
func TestApplyRefusesAnUnclosableFrontmatterFence(t *testing.T) {
|
|
||||||
_, err := Apply("---\ntitle: t\n---x\norigin: static\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
|
|
||||||
assert.True(t, errors.Is(err, ErrUnverifiable), "got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyAddsFrontmatterToContentWithoutAny(t *testing.T) {
|
|
||||||
out, err := Apply("# plain\n\norigin: static is prose\n", Stamp{Origin: "static", WrittenBy: "local-cli"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# plain"))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package search
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only the leading frontmatter is provenance; a body line that looks like a
|
|
||||||
// key is prose (the doc comment's claim, now observed).
|
|
||||||
func TestExtractProvenanceIgnoresBodyLines(t *testing.T) {
|
|
||||||
o, w := extractProvenance("---\ntitle: t\n---\norigin: static\nwritten_by: local-cli\n")
|
|
||||||
assert.Equal(t, "", o)
|
|
||||||
assert.Equal(t, "", w)
|
|
||||||
o, w = extractProvenance("# no frontmatter\norigin: static\n")
|
|
||||||
assert.Equal(t, "", o)
|
|
||||||
assert.Equal(t, "", w)
|
|
||||||
o, w = extractProvenance("---\norigin: jwt\nwritten_by: attacker\n---\norigin: static\n")
|
|
||||||
assert.Equal(t, "jwt", o)
|
|
||||||
assert.Equal(t, "attacker", w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 3 (B1): an indented "---" inside a block scalar is content, not the
|
|
||||||
// closing fence — the same rule as frontmatter.Split.
|
|
||||||
func TestExtractProvenanceUsesTheSharedFenceRule(t *testing.T) {
|
|
||||||
o, w := extractProvenance("---\nk: |\n ---\norigin: jwt\nwritten_by: attacker\n---\nbody\n")
|
|
||||||
assert.Equal(t, "jwt", o)
|
|
||||||
assert.Equal(t, "attacker", w)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -49,11 +48,6 @@ type Result struct {
|
|||||||
// (infra#72). Read from frontmatter when present, otherwise
|
// (infra#72). Read from frontmatter when present, otherwise
|
||||||
// inferred from the parent directory.
|
// inferred from the parent directory.
|
||||||
Tier string `json:"tier,omitempty"`
|
Tier string `json:"tier,omitempty"`
|
||||||
// Origin/WrittenBy surface write provenance (hyperguild#145) so a
|
|
||||||
// reader can discount an entry a connector session wrote. Omitted for
|
|
||||||
// notes written before provenance existed — never fabricated.
|
|
||||||
Origin string `json:"origin,omitempty"`
|
|
||||||
WrittenBy string `json:"written_by,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tierWeight maps the DIKW tier to a score multiplier applied right
|
// tierWeight maps the DIKW tier to a score multiplier applied right
|
||||||
@@ -152,7 +146,6 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
wing, hall := extractWingHall(string(content), rel)
|
wing, hall := extractWingHall(string(content), rel)
|
||||||
tier := extractTier(string(content), rel)
|
tier := extractTier(string(content), rel)
|
||||||
origin, writtenBy := extractProvenance(string(content))
|
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Path: rel,
|
Path: rel,
|
||||||
Title: extractTitle(string(content), d.Name()),
|
Title: extractTitle(string(content), d.Name()),
|
||||||
@@ -161,9 +154,6 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
|
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -282,7 +272,6 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
}
|
}
|
||||||
wing, hall := extractWingHall(string(content), relPath)
|
wing, hall := extractWingHall(string(content), relPath)
|
||||||
tier := extractTier(string(content), relPath)
|
tier := extractTier(string(content), relPath)
|
||||||
origin, writtenBy := extractProvenance(string(content))
|
|
||||||
return Result{
|
return Result{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
Title: extractTitle(string(content), filepath.Base(relPath)),
|
Title: extractTitle(string(content), filepath.Base(relPath)),
|
||||||
@@ -290,44 +279,9 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
|
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractProvenance reads origin/written_by from the leading frontmatter
|
|
||||||
// block only, located with the shared fence rule (frontmatter.Split: a "---"
|
|
||||||
// at column 0) so an indented "---" inside a block scalar is content, not a
|
|
||||||
// closing fence (PR #147 review round 3). A body line is never provenance.
|
|
||||||
func extractProvenance(content string) (origin, writtenBy string) {
|
|
||||||
block, _, ok := sharedfm.Split([]byte(content))
|
|
||||||
if !ok {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(block), "\n") {
|
|
||||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, val, found := strings.Cut(line, ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v := strings.Trim(strings.TrimSpace(val), `"'`)
|
|
||||||
switch strings.TrimSpace(key) {
|
|
||||||
case "origin":
|
|
||||||
if origin == "" {
|
|
||||||
origin = v
|
|
||||||
}
|
|
||||||
case "written_by":
|
|
||||||
if writtenBy == "" {
|
|
||||||
writtenBy = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return origin, writtenBy
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveRoots returns the directories to walk for the given wing/hall
|
// resolveRoots returns the directories to walk for the given wing/hall
|
||||||
// filters. Validates hall against the closed vocabulary when set.
|
// filters. Validates hall against the closed vocabulary when set.
|
||||||
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
|
|||||||
calls++
|
calls++
|
||||||
return "ready", nil
|
return "ready", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Do: %v", err)
|
t.Fatalf("Do: %v", err)
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,7 @@ func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return "ready", nil
|
return "ready", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
// Package symlinkguard is the hyperguild#157 tripwire. The brain directory is
|
|
||||||
// a Syncthing sendreceive folder and Syncthing syncs symlinks; several brain
|
|
||||||
// readers (search, vectorstore, context, graph) follow a symlink out of the
|
|
||||||
// brain and would index or return whatever it points to. Until every reader
|
|
||||||
// reads through an os.Root, a symlink inside the brain, or a directory the
|
|
||||||
// scan cannot read (which could hide one), is an incident: the server refuses
|
|
||||||
// to start on one (main), and Watch reports one that appears while it runs.
|
|
||||||
package symlinkguard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultInterval is used when Watch is given a non-positive interval.
|
|
||||||
const DefaultInterval = 5 * time.Minute
|
|
||||||
|
|
||||||
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
|
|
||||||
type Notifier interface {
|
|
||||||
Notify(ctx context.Context, msg string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result is what a scan found: symlinks, and directories it could not read
|
|
||||||
// (each of which could hide a symlink). Paths are brain-relative.
|
|
||||||
type Result struct {
|
|
||||||
Symlinks []string
|
|
||||||
Unreadable []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean reports whether the scan found nothing to act on.
|
|
||||||
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 }
|
|
||||||
|
|
||||||
// String describes the findings for a log line or alert. Paths are quoted, so
|
|
||||||
// a name containing a newline cannot blur two findings together.
|
|
||||||
func (r Result) String() string {
|
|
||||||
q := func(xs []string) string {
|
|
||||||
out := make([]string, len(xs))
|
|
||||||
for i, x := range xs {
|
|
||||||
out[i] = strconv.Quote(x)
|
|
||||||
}
|
|
||||||
return strings.Join(out, ", ")
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
if len(r.Symlinks) > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
|
|
||||||
}
|
|
||||||
if len(r.Unreadable) > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan walks the brain dir and reports every symlink in it and every
|
|
||||||
// directory it could not read, continuing past both. Only the ROOT .git is
|
|
||||||
// skipped (it is brain-sync's, and no reader walks it); a nested .git is
|
|
||||||
// scanned like any other directory. brainDir itself may be a symlink (a
|
|
||||||
// volume mount); it is resolved first. The error is non-nil when the root
|
|
||||||
// cannot be resolved (wrapping os.ErrNotExist when it is missing) or cannot be
|
|
||||||
// listed.
|
|
||||||
func Scan(brainDir string) (Result, error) {
|
|
||||||
root, err := filepath.EvalSymlinks(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("resolve brain dir: %w", err)
|
|
||||||
}
|
|
||||||
var r Result
|
|
||||||
rootGit := filepath.Join(root, ".git")
|
|
||||||
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
||||||
rel, _ := filepath.Rel(root, p)
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if err != nil {
|
|
||||||
if p == root {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.Unreadable = append(r.Unreadable, rel)
|
|
||||||
if d != nil && d.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if d.IsDir() && p == rootGit {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
if d.Type()&os.ModeSymlink != 0 {
|
|
||||||
r.Symlinks = append(r.Symlinks, rel)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
sort.Strings(r.Symlinks)
|
|
||||||
sort.Strings(r.Unreadable)
|
|
||||||
if walkErr != nil {
|
|
||||||
// Only the root's own error reaches here (deeper errors are recorded
|
|
||||||
// as Unreadable). A root that cannot be listed cannot be shown clean
|
|
||||||
// (PR #159 round 2: discarding this reported clean while search
|
|
||||||
// served a planted symlink).
|
|
||||||
return r, fmt.Errorf("scan brain dir: %w", walkErr)
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch rescans every interval (DefaultInterval if non-positive) until ctx
|
|
||||||
// ends, and notifies once per distinct non-clean result. A result counts as
|
|
||||||
// reported only after Notify succeeds, so an alert lost to a notifier outage
|
|
||||||
// is retried on the next tick.
|
|
||||||
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = DefaultInterval
|
|
||||||
}
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
reported := ""
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
}
|
|
||||||
r, err := Scan(brainDir)
|
|
||||||
var key, msg string
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
key = "error: " + err.Error()
|
|
||||||
msg = "brain symlink tripwire (hyperguild#157): cannot scan the brain dir: " + err.Error()
|
|
||||||
case r.Clean():
|
|
||||||
reported = ""
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
key = r.String()
|
|
||||||
msg = "brain symlink tripwire (hyperguild#157): " + key +
|
|
||||||
". Readers may follow symlinks out of the brain; remove them."
|
|
||||||
}
|
|
||||||
if key == reported {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Error("brain symlink tripwire", "finding", key)
|
|
||||||
if n == nil {
|
|
||||||
reported = key
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := n.Notify(ctx, msg); err != nil {
|
|
||||||
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reported = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package symlinkguard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#157: the brain dir is a Syncthing sendreceive folder and
|
|
||||||
// Syncthing syncs symlinks; several readers follow them out of the brain.
|
|
||||||
// Until every reader goes through an os.Root, a symlink anywhere in the brain,
|
|
||||||
// or a directory the scan cannot read (which could hide one), is an incident.
|
|
||||||
|
|
||||||
func TestScanReportsSymlinksAtEveryDepthIncludingNestedGit(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts", ".git"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, ".git", "objects"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join(outside, "f"), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "dirlink")))
|
|
||||||
// PR #159 review: only the ROOT .git is skipped; a nested .git is not
|
|
||||||
// special to the brain readers, which walk into it.
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join(outside, "g"), filepath.Join(brain, "wiki", "w", "facts", ".git", "leak.md")))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brain, ".git", "objects", "alt")))
|
|
||||||
|
|
||||||
r, err := Scan(brain)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, []string{"wiki/w/facts/leak.md", "wiki/dirlink", "wiki/w/facts/.git/leak.md"}, r.Symlinks)
|
|
||||||
assert.Empty(t, r.Unreadable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #159 review: WalkDir stopped at the first unreadable directory and the
|
|
||||||
// startup check passed, while search (which keeps walking) served a planted
|
|
||||||
// symlink. Scan now records the unreadable dir and keeps going.
|
|
||||||
func TestScanReportsUnreadableDirsAndKeepsWalkingPastThem(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) })
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "zzz-planted")))
|
|
||||||
|
|
||||||
r, err := Scan(brain)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"aaa"}, r.Unreadable)
|
|
||||||
assert.Equal(t, []string{"zzz-planted"}, r.Symlinks, "the walk continues past the unreadable dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanResolvesASymlinkedRootAndReportsSymlinkedEntriesInsideIt(t *testing.T) {
|
|
||||||
real := t.TempDir()
|
|
||||||
link := filepath.Join(t.TempDir(), "brain")
|
|
||||||
require.NoError(t, os.Symlink(real, link))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(real, "n.md"), []byte("x"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(real, "inner")))
|
|
||||||
r, err := Scan(link)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"inner"}, r.Symlinks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanOfAMissingRootIsErrNotExist(t *testing.T) {
|
|
||||||
_, err := Scan(filepath.Join(t.TempDir(), "nope"))
|
|
||||||
assert.True(t, errors.Is(err, os.ErrNotExist), "got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type recNotifier struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
msgs []string
|
|
||||||
fail int // fail the first N calls
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *recNotifier) Notify(_ context.Context, m string) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
r.msgs = append(r.msgs, m)
|
|
||||||
if r.fail > 0 {
|
|
||||||
r.fail--
|
|
||||||
return errors.New("ntfy down")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *recNotifier) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.msgs) }
|
|
||||||
|
|
||||||
func TestWatchNotifiesOncePerDistinctSetAndAgainForANewSet(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
assert.Zero(t, n.count(), "a clean brain raises nothing")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "first")))
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
assert.Equal(t, 1, n.count(), "the same set is reported once, not every tick")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "second")))
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #159 review: last was recorded before Notify, so an alert lost to an
|
|
||||||
// ntfy outage was never retried while the symlink stayed.
|
|
||||||
func TestWatchRetriesAnAlertThatFailedToSend(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
|
|
||||||
n := &recNotifier{fail: 2}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
assert.Equal(t, 3, n.count(), "after one successful send, no more")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWatchAlertsOnAnUnreadableDir(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root reads mode-000 directories")
|
|
||||||
}
|
|
||||||
brain := t.TempDir()
|
|
||||||
locked := filepath.Join(brain, "locked")
|
|
||||||
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
||||||
require.NoError(t, os.Chmod(locked, 0o000))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
assert.Contains(t, n.msgs[0], "locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #159 review: a non-positive interval panicked time.NewTicker inside the
|
|
||||||
// goroutine and crashlooped the server.
|
|
||||||
func TestWatchToleratesANonPositiveInterval(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { Watch(ctx, brain, 0, nil); close(done) }()
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("Watch did not return on cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #159 round 2: a brain root that can be entered but not listed (mode
|
|
||||||
// 0100) made WalkDir fail at the root; the error was discarded and Scan
|
|
||||||
// reported clean, while search (which goes straight into wiki/) still served
|
|
||||||
// a planted symlink. A root that cannot be listed is now an error.
|
|
||||||
func TestScanOfAnUnlistableRootIsAnError(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root lists mode-0100 directories")
|
|
||||||
}
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "wiki", "leak.md")))
|
|
||||||
require.NoError(t, os.Chmod(brain, 0o100))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
|
|
||||||
_, err := Scan(brain)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.False(t, errors.Is(err, os.ErrNotExist), "an unlistable root is not a missing root")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same set that disappears and comes back is reported again.
|
|
||||||
func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
link := filepath.Join(brain, "planted")
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
require.NoError(t, os.Remove(link))
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
// reachable database.
|
// reachable database.
|
||||||
//
|
//
|
||||||
// To run:
|
// To run:
|
||||||
//
|
|
||||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||||
// go test ./internal/vectorstore/... -run Integration
|
// go test ./internal/vectorstore/... -run Integration
|
||||||
func dsn(t *testing.T) string {
|
func dsn(t *testing.T) string {
|
||||||
|
|||||||
@@ -48,25 +48,8 @@ func Start(ctx context.Context, cfg Config) {
|
|||||||
func processDir(ctx context.Context, cfg Config, date string) []error {
|
func processDir(ctx context.Context, cfg Config, date string) []error {
|
||||||
rawDir := filepath.Join(cfg.BrainDir, "raw")
|
rawDir := filepath.Join(cfg.BrainDir, "raw")
|
||||||
|
|
||||||
// Every read goes through an os.Root on raw/ (hyperguild#153): raw/ is
|
|
||||||
// writable via Syncthing, and a file swapped for a symlink between the walk
|
|
||||||
// and the read must not lead out of it. A symlinked raw/ is refused.
|
|
||||||
if fi, lerr := os.Lstat(rawDir); lerr != nil {
|
|
||||||
if os.IsNotExist(lerr) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []error{fmt.Errorf("stat raw dir: %w", lerr)}
|
|
||||||
} else if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
|
||||||
return []error{fmt.Errorf("raw dir %s is not a real directory; refusing to ingest from it", rawDir)}
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(rawDir)
|
|
||||||
if err != nil {
|
|
||||||
return []error{fmt.Errorf("open raw root: %w", err)}
|
|
||||||
}
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
err = filepath.WalkDir(rawDir, func(path string, d os.DirEntry, err error) error {
|
err := filepath.WalkDir(rawDir, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -76,16 +59,10 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip processed/, failed/, and rejected/ subdirectories entirely.
|
// Skip processed/ and failed/ subdirectories entirely.
|
||||||
// rejected/ (hyperguild#81, brain_reject) holds notes a human already
|
|
||||||
// looked at and declined — walking into it would run the extraction
|
|
||||||
// pipeline over a note and publish it to the wiki, the exact opposite
|
|
||||||
// of the human's decision. The whole reason RejectNote moves a note
|
|
||||||
// there instead of leaving it in raw/ was to keep it out of every
|
|
||||||
// path that treats raw/ as "awaiting processing" — this is that path.
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
name := d.Name()
|
name := d.Name()
|
||||||
if name == "processed" || name == "failed" || name == "rejected" {
|
if name == "processed" || name == "failed" {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -103,10 +80,6 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
if strings.HasPrefix(d.Name(), "tunnel-candidates-") {
|
if strings.HasPrefix(d.Name(), "tunnel-candidates-") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Only regular files are notes; a symlink's target may be anywhere.
|
|
||||||
if !d.Type().IsRegular() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip files that have already been processed or permanently failed.
|
// Skip files that have already been processed or permanently failed.
|
||||||
if _, err := os.Stat(path + ".processed"); err == nil {
|
if _, err := os.Stat(path + ".processed"); err == nil {
|
||||||
@@ -116,7 +89,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
|
if err := processFile(ctx, cfg, path, date); err != nil {
|
||||||
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -130,15 +103,11 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
|
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
|
||||||
// and writes a marker file next to the original so the watcher skips it next poll.
|
// and writes a marker file next to the original so the watcher skips it next poll.
|
||||||
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
|
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
|
||||||
func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error {
|
func processFile(ctx context.Context, cfg Config, path, date string) error {
|
||||||
filename := filepath.Base(path)
|
filename := filepath.Base(path)
|
||||||
source := deriveSource(filename)
|
source := deriveSource(filename)
|
||||||
rel, err := filepath.Rel(rawDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("relative path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := extract.TextIn(root, rel)
|
content, err := extract.Text(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("extract text: %w", err)
|
return fmt.Errorf("extract text: %w", err)
|
||||||
}
|
}
|
||||||
@@ -151,7 +120,7 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
return fmt.Errorf("mkdir failed dir: %w", mkErr)
|
return fmt.Errorf("mkdir failed dir: %w", mkErr)
|
||||||
}
|
}
|
||||||
dest := filepath.Join(failedDir, filename)
|
dest := filepath.Join(failedDir, filename)
|
||||||
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil {
|
if cpErr := copyFile(path, dest); cpErr != nil {
|
||||||
return fmt.Errorf("copy to failed: %w", cpErr)
|
return fmt.Errorf("copy to failed: %w", cpErr)
|
||||||
}
|
}
|
||||||
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
|
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
|
||||||
@@ -179,7 +148,7 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
base := strings.TrimSuffix(filename, ext)
|
base := strings.TrimSuffix(filename, ext)
|
||||||
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
|
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
|
||||||
}
|
}
|
||||||
if err := copyFromRoot(root, rel, dest); err != nil {
|
if err := copyFile(path, dest); err != nil {
|
||||||
return fmt.Errorf("copy to processed: %w", err)
|
return fmt.Errorf("copy to processed: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
|
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
|
||||||
@@ -190,6 +159,26 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyFile copies src to dst, creating dst if it doesn't exist.
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open src: %w", err)
|
||||||
|
}
|
||||||
|
defer in.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create dst: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close() //nolint:errcheck
|
||||||
|
return fmt.Errorf("copy: %w", err)
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// deriveSource turns a filename into a human-readable source name.
|
// deriveSource turns a filename into a human-readable source name.
|
||||||
// "shape-up-book.md" → "Shape Up Book"
|
// "shape-up-book.md" → "Shape Up Book"
|
||||||
func deriveSource(filename string) string {
|
func deriveSource(filename string) string {
|
||||||
@@ -226,30 +215,3 @@ func appendWatcherLog(brainDir, filename string, runErr error, date string) erro
|
|||||||
}
|
}
|
||||||
return f.Close()
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFromRoot archives a raw/ file read through the root, refusing anything
|
|
||||||
// that is not a regular file, so a swapped-in symlink cannot copy an outside
|
|
||||||
// file into raw/processed/ or raw/failed/ (hyperguild#153).
|
|
||||||
func copyFromRoot(root *os.Root, rel, dst string) error {
|
|
||||||
fi, err := root.Lstat(rel)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !fi.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("%s: not a regular file", rel)
|
|
||||||
}
|
|
||||||
in, _, err := extract.OpenRegular(root, rel)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = in.Close() }()
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
|
||||||
_ = out.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return out.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,20 +176,15 @@ func TestDeriveSource(t *testing.T) {
|
|||||||
func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
||||||
brainDir := setupBrainDir(t)
|
brainDir := setupBrainDir(t)
|
||||||
|
|
||||||
// Create processed/, failed/, and rejected/ subdirs with files inside.
|
// Create processed/ and failed/ subdirs with files inside.
|
||||||
for _, sub := range []string{"processed/2026-04-22", "failed", "rejected"} {
|
for _, sub := range []string{"processed/2026-04-22", "failed"} {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755))
|
||||||
}
|
}
|
||||||
|
|
||||||
processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md")
|
processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md")
|
||||||
failedFile := filepath.Join(brainDir, "raw", "failed", "broken-file.md")
|
failedFile := filepath.Join(brainDir, "raw", "failed", "broken-file.md")
|
||||||
// hyperguild#81/#133: a human already declined this note via brain_reject.
|
|
||||||
// If the watcher walks into rejected/, it re-ingests and publishes it to
|
|
||||||
// the wiki — the exact opposite of the human's decision.
|
|
||||||
rejectedFile := filepath.Join(brainDir, "raw", "rejected", "declined-file.md")
|
|
||||||
require.NoError(t, os.WriteFile(processedFile, []byte("old"), 0o644))
|
require.NoError(t, os.WriteFile(processedFile, []byte("old"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(failedFile, []byte("broken"), 0o644))
|
require.NoError(t, os.WriteFile(failedFile, []byte("broken"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(rejectedFile, []byte("---\nrejected_at: 2026-09-23T00:00:00Z\n---\ndeclined"), 0o644))
|
|
||||||
|
|
||||||
// Also place a valid file in raw/ root that should be processed.
|
// Also place a valid file in raw/ root that should be processed.
|
||||||
validFile := filepath.Join(brainDir, "raw", "valid.md")
|
validFile := filepath.Join(brainDir, "raw", "valid.md")
|
||||||
@@ -231,13 +223,11 @@ func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
|||||||
// Complete should have been called exactly once (for valid.md, not for files in subdirs).
|
// Complete should have been called exactly once (for valid.md, not for files in subdirs).
|
||||||
assert.Len(t, processedSources, 1, "only the file in raw/ root should be processed")
|
assert.Len(t, processedSources, 1, "only the file in raw/ root should be processed")
|
||||||
|
|
||||||
// Files in processed/, failed/, and rejected/ must remain untouched.
|
// Files in processed/ and failed/ must remain untouched.
|
||||||
_, err := os.Stat(processedFile)
|
_, err := os.Stat(processedFile)
|
||||||
assert.NoError(t, err, "processed subdir file should be untouched")
|
assert.NoError(t, err, "processed subdir file should be untouched")
|
||||||
_, err = os.Stat(failedFile)
|
_, err = os.Stat(failedFile)
|
||||||
assert.NoError(t, err, "failed subdir file should be untouched")
|
assert.NoError(t, err, "failed subdir file should be untouched")
|
||||||
_, err = os.Stat(rejectedFile)
|
|
||||||
assert.NoError(t, err, "rejected subdir file should be untouched, never re-ingested")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
|
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
|
||||||
@@ -285,102 +275,3 @@ func TestProcessDir_SkipsTunnelCandidateFiles(t *testing.T) {
|
|||||||
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md"))
|
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md"))
|
||||||
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/")
|
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#153: raw/ is writable via Syncthing, and the watcher ingests
|
|
||||||
// whatever lands there with no API call at all. WalkDir yields symlinked
|
|
||||||
// files, and extract.Text followed them, so a symlink to any pod-readable
|
|
||||||
// file would be extracted into the brain. The watcher now ingests only
|
|
||||||
// regular files, read through an os.Root on raw/.
|
|
||||||
func TestProcessDir_IngestsTheRealNoteAndSkipsTheSymlinkBesideIt(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
|
||||||
require.NoError(t, os.WriteFile(outside, []byte("SENTINEL-153-WATCHER"), 0o644))
|
|
||||||
// "a-" sorts before "real": a walk that stopped at the first symlink
|
|
||||||
// (filepath.SkipAll — one synced link halting all ingestion) would fail
|
|
||||||
// the call count below (PR #156 review).
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "a-leak.md")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "real.md"), []byte("real note"), 0o644))
|
|
||||||
|
|
||||||
var sawSentinel bool
|
|
||||||
calls := 0
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
|
||||||
Complete: func(_ context.Context, _, user string) (string, error) {
|
|
||||||
calls++
|
|
||||||
if strings.Contains(user, "SENTINEL-153-WATCHER") {
|
|
||||||
sawSentinel = true
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
|
|
||||||
return string(b), nil
|
|
||||||
},
|
|
||||||
Schema: "# Schema",
|
|
||||||
}}
|
|
||||||
errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02"))
|
|
||||||
assert.Empty(t, errs)
|
|
||||||
assert.False(t, sawSentinel, "the symlink target must never reach the pipeline")
|
|
||||||
assert.Equal(t, 1, calls, "exactly the real note is ingested; the symlink is not a note")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A symlinked raw/ is refused outright: os.OpenRoot would otherwise follow
|
|
||||||
// it and confine reads to wherever it points.
|
|
||||||
func TestProcessDir_RefusesASymlinkedRawDir(t *testing.T) {
|
|
||||||
brainDir := t.TempDir()
|
|
||||||
elsewhere := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("SENTINEL-153-RAWLINK"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brainDir, "raw")))
|
|
||||||
calls := 0
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
|
||||||
Complete: func(context.Context, string, string) (string, error) { calls++; return "[]", nil },
|
|
||||||
Schema: "# Schema",
|
|
||||||
}}
|
|
||||||
errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02"))
|
|
||||||
assert.NotEmpty(t, errs, "a symlinked raw/ must be reported, not silently ingested")
|
|
||||||
assert.Zero(t, calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #156: a FIFO swapped into raw/ between the watcher's checks and its
|
|
||||||
// read or archive copy must not hang the watcher, which runs unattended.
|
|
||||||
func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
raw := filepath.Join(brainDir, "raw")
|
|
||||||
var stop atomic.Bool
|
|
||||||
go func() {
|
|
||||||
for i := 0; !stop.Load(); i++ {
|
|
||||||
tmp := filepath.Join(raw, ".swap")
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
if i%2 == 0 {
|
|
||||||
_ = os.WriteFile(tmp, []byte("x"), 0o644)
|
|
||||||
} else {
|
|
||||||
_ = syscall.Mkfifo(tmp, 0o644)
|
|
||||||
}
|
|
||||||
_ = os.Rename(tmp, filepath.Join(raw, "r.md"))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
|
||||||
Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
b, _ := json.Marshal([]pipeline.RawPage{{Title: "R", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
|
|
||||||
return string(b), nil
|
|
||||||
},
|
|
||||||
Schema: "# Schema",
|
|
||||||
}}
|
|
||||||
var iters atomic.Int64
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
|
||||||
_ = os.Remove(filepath.Join(raw, "r.md.processed"))
|
|
||||||
_ = os.Remove(filepath.Join(raw, "r.md.failed"))
|
|
||||||
_ = processDir(context.Background(), cfg, "2026-10-03")
|
|
||||||
iters.Add(1)
|
|
||||||
}
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
stop.Store(true)
|
|
||||||
case <-time.After(8 * time.Second):
|
|
||||||
stop.Store(true)
|
|
||||||
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
|
|
||||||
_ = f.Close()
|
|
||||||
}
|
|
||||||
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,259 +36,32 @@ type section struct {
|
|||||||
// Sections are merged by strategy: bullet sections union unique lines,
|
// Sections are merged by strategy: bullet sections union unique lines,
|
||||||
// append sections concatenate, all others keep a's version. Sections in b
|
// append sections concatenate, all others keep a's version. Sections in b
|
||||||
// not present in a are appended.
|
// not present in a are appended.
|
||||||
// Merge discards any disagreement silently; prefer MergeReport where the
|
|
||||||
// caller can act on it.
|
|
||||||
func Merge(a, b Page) Page {
|
func Merge(a, b Page) Page {
|
||||||
page, _ := MergeReport(a, b)
|
|
||||||
return page
|
|
||||||
}
|
|
||||||
|
|
||||||
// MergeReport is Merge, plus the headings of sections where b carried a
|
|
||||||
// different version of a section a already had — and lost.
|
|
||||||
//
|
|
||||||
// That loss is the property which makes ingest safe to run unattended (#103):
|
|
||||||
// a regenerating writer can add to a note but never rewrite it. But the
|
|
||||||
// discarded text is also a signal, and until now it went in the bin: the source
|
|
||||||
// now says something different about a section we already hold. Nobody could
|
|
||||||
// tell "the source is unchanged" from "the source changed its mind and we
|
|
||||||
// ignored it".
|
|
||||||
//
|
|
||||||
// Deliberately NOT a semantic contradiction check. This reports textual
|
|
||||||
// disagreement, which is cheap, deterministic, and needs no model on the write
|
|
||||||
// path. Deciding whether a disagreement is a genuine contradiction stays with
|
|
||||||
// the Judge-gated enrich pass that already exists (brain-gardener#175), which
|
|
||||||
// is approval-gated by design — detection here, adjudication there.
|
|
||||||
func MergeReport(a, b Page) (Page, []string) {
|
|
||||||
fmA, preambleA, secsA := parseSections(a.Content)
|
fmA, preambleA, secsA := parseSections(a.Content)
|
||||||
fmB, _, secsB := parseSections(b.Content)
|
_, _, secsB := parseSections(b.Content)
|
||||||
|
|
||||||
var conflicts []string
|
idx := make(map[string]int, len(secsA))
|
||||||
|
|
||||||
// Sections in secsA sharing a normalized heading (pre-existing corpus
|
|
||||||
// drift — "## Notes" and "## NOTES" both already on disk, the exact
|
|
||||||
// pattern this normalization exists to catch going forward, found in
|
|
||||||
// review of PR #130/#132) are matched to b's occurrences of that same
|
|
||||||
// heading POSITIONALLY: b's 1st occurrence of the key pairs with a's
|
|
||||||
// 1st, b's 2nd with a's 2nd, and so on. A single "last write wins" (or
|
|
||||||
// even "first write wins") map from key to one index would instead
|
|
||||||
// route every one of b's occurrences to the SAME on-disk section —
|
|
||||||
// which either freezes every duplicate but one (never receiving
|
|
||||||
// updates again) or, worse, compares mismatched pairs and reports a
|
|
||||||
// disagreement between two sections that were never supposed to be
|
|
||||||
// compared, false-positiving on the plain self-merge case a revision
|
|
||||||
// pass produces when the model echoes a page back unchanged (found in
|
|
||||||
// review of PR #132).
|
|
||||||
byKey := make(map[string][]int, len(secsA))
|
|
||||||
for i, s := range secsA {
|
for i, s := range secsA {
|
||||||
key := normalizeHeading(s.heading)
|
idx[s.heading] = i
|
||||||
byKey[key] = append(byKey[key], i)
|
|
||||||
}
|
}
|
||||||
cursor := make(map[string]int, len(byKey))
|
|
||||||
|
|
||||||
for _, sB := range secsB {
|
for _, sB := range secsB {
|
||||||
key := normalizeHeading(sB.heading)
|
i, exists := idx[sB.heading]
|
||||||
indices := byKey[key]
|
if !exists {
|
||||||
pos := cursor[key]
|
idx[sB.heading] = len(secsA)
|
||||||
cursor[key] = pos + 1
|
|
||||||
|
|
||||||
if pos >= len(indices) {
|
|
||||||
// b has more occurrences of this heading than a does (including
|
|
||||||
// the case where a has none at all) — the extra is new content,
|
|
||||||
// appended as its own section and registered so a THIRD
|
|
||||||
// occurrence of the same heading in b pairs with this one
|
|
||||||
// rather than appending yet another duplicate.
|
|
||||||
byKey[key] = append(byKey[key], len(secsA))
|
|
||||||
secsA = append(secsA, sB)
|
secsA = append(secsA, sB)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
i := indices[pos]
|
|
||||||
sA := secsA[i]
|
sA := secsA[i]
|
||||||
switch {
|
switch {
|
||||||
case bulletSectionsNorm[key]:
|
case bulletSections[sB.heading]:
|
||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSectionsNorm[key]:
|
case appendSections[sB.heading]:
|
||||||
secsA[i].content = appendNew(sA.content, sB.content)
|
secsA[i].content = appendNew(sA.content, sB.content)
|
||||||
default:
|
|
||||||
// a wins — but say that b disagreed, rather than dropping it mute.
|
|
||||||
if canonicalBlock(sA.content) != canonicalBlock(sB.content) {
|
|
||||||
conflicts = append(conflicts, sB.heading)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}, conflicts
|
return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)}
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeHeading returns the key Merge uses to decide "is this the same
|
|
||||||
// section" — trimmed and case-folded, so "Key Claims" and "Key claims" match
|
|
||||||
// (hyperguild#106: heading casing drift must not fork a section). It is used
|
|
||||||
// only for that comparison; the on-disk section's original heading text
|
|
||||||
// (secsA[i].heading, untouched here) is what actually gets written out, so a
|
|
||||||
// human's heading casing is never silently rewritten by a merge.
|
|
||||||
func normalizeHeading(h string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(h))
|
|
||||||
}
|
|
||||||
|
|
||||||
// bulletSectionsNorm and appendSectionsNorm are bulletSections/appendSections
|
|
||||||
// keyed by normalizeHeading instead of exact text, so a section's merge
|
|
||||||
// strategy (union bullets, append) is chosen by the same normalized identity
|
|
||||||
// Merge uses to match it — otherwise a heading that matched an on-disk
|
|
||||||
// section (case-insensitively) could still fail the exact-string strategy
|
|
||||||
// lookup and silently fall through to "keep a's version," dropping b's
|
|
||||||
// content instead of unioning or appending it.
|
|
||||||
var bulletSectionsNorm = normalizeSet(bulletSections)
|
|
||||||
var appendSectionsNorm = normalizeSet(appendSections)
|
|
||||||
|
|
||||||
func normalizeSet(names map[string]bool) map[string]bool {
|
|
||||||
out := make(map[string]bool, len(names))
|
|
||||||
for k := range names {
|
|
||||||
out[normalizeHeading(k)] = true
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
|
||||||
// on a page it is revising. Everything else on disk wins.
|
|
||||||
//
|
|
||||||
// #103 made the on-disk frontmatter win wholesale, which is what stops a
|
|
||||||
// regenerating writer from overwriting human-set metadata — a title someone
|
|
||||||
// fixed, or the claim-property envelope (brain#21). The cost was that fields
|
|
||||||
// the generator legitimately owns froze at their first-ingest values: when it
|
|
||||||
// last ran, and what it last saw.
|
|
||||||
//
|
|
||||||
// An explicit allowlist rather than a carve-out: the ownership boundary is
|
|
||||||
// nameable, and a field nobody has thought about stays human-owned by default,
|
|
||||||
// which is the safe direction.
|
|
||||||
var generatorOwned = map[string]bool{
|
|
||||||
"last_updated": true,
|
|
||||||
"source_hash": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmLine is one line of a frontmatter block: a parsed key/value pair, or a
|
|
||||||
// raw line (blank, comment, or anything else that isn't a simple "key:
|
|
||||||
// value") kept verbatim. Same shape as api.frontmatter's fmLine — this
|
|
||||||
// package doesn't import that one (it's unexported there, and a's/b's
|
|
||||||
// frontmatter here always arrives as a string, not a parsed struct), so the
|
|
||||||
// small, already-proven set()-appends-if-absent behavior is reproduced
|
|
||||||
// locally rather than string-index surgery reinvented per call site.
|
|
||||||
type fmLine struct {
|
|
||||||
key, value, raw string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFrontmatterBlock parses a "---\n...\n---\n" fenced block (the shape
|
|
||||||
// parseSections produces) into an ordered line list. A block with no
|
|
||||||
// opening/closing fence — including the empty string, a page with no
|
|
||||||
// frontmatter at all — returns nil, ok=false: there is nothing to update in
|
|
||||||
// place, only something to potentially create.
|
|
||||||
func parseFrontmatterBlock(block string) (lines []fmLine, ok bool) {
|
|
||||||
if !strings.HasPrefix(block, "---\n") {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
rest := block[len("---\n"):]
|
|
||||||
end := strings.Index(rest, "\n---\n")
|
|
||||||
if end < 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(rest[:end], "\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". Checked before Cut so a
|
|
||||||
// colon inside one never reaches the key/value path at all.
|
|
||||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
|
||||||
lines = append(lines, fmLine{raw: line})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, val, found := strings.Cut(line, ":")
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if !found || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
|
||||||
lines = append(lines, fmLine{raw: line})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, fmLine{key: key, value: strings.TrimSpace(val)})
|
|
||||||
}
|
|
||||||
return lines, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// setLine updates key's value in place if lines already has it, or appends
|
|
||||||
// a new entry if it doesn't — the one behavior the original refreshOwned
|
|
||||||
// got wrong (PR #126 review, round 2: it only ever updated in place).
|
|
||||||
func setLine(lines []fmLine, key, value string) []fmLine {
|
|
||||||
for i := range lines {
|
|
||||||
if lines[i].key == key {
|
|
||||||
lines[i].value = value
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(lines, fmLine{key: key, value: value})
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderFrontmatterBlock(lines []fmLine) string {
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("---\n")
|
|
||||||
for _, l := range lines {
|
|
||||||
switch {
|
|
||||||
case l.key == "":
|
|
||||||
sb.WriteString(l.raw)
|
|
||||||
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, if invisible, byte sequence, and this file's
|
|
||||||
// whole purpose is byte-stability across an unrelated merge.
|
|
||||||
sb.WriteString(l.key)
|
|
||||||
sb.WriteByte(':')
|
|
||||||
default:
|
|
||||||
sb.WriteString(l.key)
|
|
||||||
sb.WriteString(": ")
|
|
||||||
sb.WriteString(l.value)
|
|
||||||
}
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
sb.WriteString("---\n")
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshOwned returns a's frontmatter with the generator-owned fields taken
|
|
||||||
// from b where b supplies them. Fields absent from b keep a's value, so a
|
|
||||||
// generator that stops emitting a field cannot blank it.
|
|
||||||
//
|
|
||||||
// A field b supplies that a doesn't have yet — every page ingested before
|
|
||||||
// this mechanism existed has no source_hash line at all — is appended, not
|
|
||||||
// dropped (round 1 of review: dropping it silently meant source_hash never
|
|
||||||
// populated for the pre-existing corpus). If a has no frontmatter fence at
|
|
||||||
// all, one is created to hold the new fields rather than splicing raw
|
|
||||||
// "key: value" text into the body (round 2: that corrupted the page and the
|
|
||||||
// fields would duplicate on every subsequent merge, since unfenced text
|
|
||||||
// isn't recognized as frontmatter by parseSections on the next pass).
|
|
||||||
func refreshOwned(a, b string) string {
|
|
||||||
bLines, _ := parseFrontmatterBlock(b)
|
|
||||||
bVals := make(map[string]string)
|
|
||||||
for _, l := range bLines {
|
|
||||||
if l.key != "" && generatorOwned[l.key] {
|
|
||||||
bVals[l.key] = l.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(bVals) == 0 {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
aLines, _ := parseFrontmatterBlock(a) // ok==false (no fence) -> aLines nil, same as "start fresh"
|
|
||||||
|
|
||||||
// Sorted for a deterministic, byte-stable result when more than one
|
|
||||||
// field is appended in the same call — map iteration order isn't.
|
|
||||||
keys := make([]string, 0, len(bVals))
|
|
||||||
for k := range bVals {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
aLines = setLine(aLines, k, bVals[k])
|
|
||||||
}
|
|
||||||
return renderFrontmatterBlock(aLines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSections splits markdown into its frontmatter, its preamble (content
|
// parseSections splits markdown into its frontmatter, its preamble (content
|
||||||
@@ -384,21 +156,6 @@ func canonicalLine(line string) string {
|
|||||||
return strings.Join(strings.Fields(line), " ")
|
return strings.Join(strings.Fields(line), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalBlock applies canonicalLine per line of a multi-line section body
|
|
||||||
// — the same whitespace-insensitivity appendNew/mergeBullets already give a
|
|
||||||
// single line, extended to a whole section for MergeReport's disagreement
|
|
||||||
// check (found in review, PR #129: comparing raw strings.TrimSpace only
|
|
||||||
// tolerated leading/trailing whitespace, not a re-extraction's mid-line
|
|
||||||
// spacing, which every other comparison in this file already tolerates).
|
|
||||||
func canonicalBlock(block string) string {
|
|
||||||
lines := strings.Split(block, "\n")
|
|
||||||
out := make([]string, len(lines))
|
|
||||||
for i, l := range lines {
|
|
||||||
out[i] = canonicalLine(l)
|
|
||||||
}
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendNew concatenates the lines of b that a does not already carry.
|
// appendNew concatenates the lines of b that a does not already carry.
|
||||||
//
|
//
|
||||||
// Append sections used to concatenate wholesale, which is correct exactly once.
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
||||||
|
|||||||
@@ -174,335 +174,3 @@ title: X
|
|||||||
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
||||||
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
|
|
||||||
// #103 made on-disk frontmatter win wholesale, which is what protects
|
|
||||||
// human-set fields from a regenerating writer. The cost was that fields the
|
|
||||||
// generator legitimately owns — when it last ran, what it last saw — froze
|
|
||||||
// at their first-ingest values. An explicit owned-set is the boundary:
|
|
||||||
// named fields the generator may refresh, everything else human-owned.
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
last_updated: 2026-01-01
|
|
||||||
source_hash: aaaa
|
|
||||||
half_life: durable
|
|
||||||
claim_provenance: asserted
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Original.
|
|
||||||
`}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'Renamed by the model'
|
|
||||||
last_updated: 2026-08-22
|
|
||||||
source_hash: bbbb
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Rewritten by the model.
|
|
||||||
`}
|
|
||||||
|
|
||||||
got := Merge(existing, fresh).Content
|
|
||||||
|
|
||||||
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
|
|
||||||
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
|
|
||||||
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
|
|
||||||
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
|
|
||||||
assert.Contains(t, got, "claim_provenance: asserted")
|
|
||||||
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
|
||||||
assert.NotContains(t, got, "Renamed by the model")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_GeneratorOwnedFieldsAreAppendedWhenAPageNeverHadThemBefore(t *testing.T) {
|
|
||||||
// Found in review of PR #126: refreshOwned only replaced a generator-owned
|
|
||||||
// line that already existed in a's frontmatter — it never appended one a
|
|
||||||
// didn't have. Every page ingested before this feature shipped has no
|
|
||||||
// source_hash line at all, so on its first re-ingest after deploy, the
|
|
||||||
// fresh hash was silently dropped and the skip-on-unchanged feature this
|
|
||||||
// PR exists to build would never engage for the entire pre-existing
|
|
||||||
// corpus, permanently, with nothing telling anyone.
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Original.
|
|
||||||
`}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'Renamed by the model'
|
|
||||||
last_updated: 2026-08-22
|
|
||||||
source_hash: bbbb
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Rewritten by the model.
|
|
||||||
`}
|
|
||||||
|
|
||||||
got := Merge(existing, fresh).Content
|
|
||||||
|
|
||||||
// Exact frontmatter block, not Contains: the second review round found
|
|
||||||
// that a substring check doesn't prove the fields landed inside a valid
|
|
||||||
// fence, in the right order, or that the fence itself wasn't corrupted —
|
|
||||||
// two independent mutations (closeIdx targeting the wrong fence, reversed
|
|
||||||
// sort) both survived a Contains-only version of this test.
|
|
||||||
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_GeneratorOwnedFieldsCreateAFrontmatterBlockWhenThePageHadNoneAtAll(t *testing.T) {
|
|
||||||
// Second review round on PR #126: when a has no frontmatter fence at all,
|
|
||||||
// the append fallback spliced raw, unfenced "key: value" lines straight
|
|
||||||
// into the body -- worse than the original bug, because the next
|
|
||||||
// parseSections call wouldn't recognize them as frontmatter (they don't
|
|
||||||
// start with `---`), so they'd be absorbed as ordinary preamble text and
|
|
||||||
// re-duplicate on every subsequent merge instead of updating in place.
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: "## Summary\n\nOriginal.\n"}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
last_updated: 2026-08-22
|
|
||||||
source_hash: bbbb
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Rewritten by the model.
|
|
||||||
`}
|
|
||||||
|
|
||||||
got := Merge(existing, fresh).Content
|
|
||||||
|
|
||||||
want := "---\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_GeneratorOwnedFieldsMixedReplaceAndAppend(t *testing.T) {
|
|
||||||
// a already has last_updated (replace in place) but not source_hash
|
|
||||||
// (append) -- the two code paths in the same call, together.
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
last_updated: 2026-01-01
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Original.
|
|
||||||
`}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
last_updated: 2026-08-22
|
|
||||||
source_hash: bbbb
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Rewritten by the model.
|
|
||||||
`}
|
|
||||||
|
|
||||||
got := Merge(existing, fresh).Content
|
|
||||||
|
|
||||||
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- hyperguild#106: heading casing drift must not fork a section ----------
|
|
||||||
// Page-aware extraction asks the model to revise the page it's given, but
|
|
||||||
// nothing guarantees the model reproduces a heading's exact on-disk casing
|
|
||||||
// ("Key Claims" vs "Key claims"). Without normalizing the match key, that
|
|
||||||
// drift silently creates a near-duplicate section instead of updating the
|
|
||||||
// original — the failure mode #106 exists to close.
|
|
||||||
|
|
||||||
func TestMerge_HeadingCaseDriftMatchesSameSection(t *testing.T) {
|
|
||||||
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- Appetite beats estimation.\n"}
|
|
||||||
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key claims\n\n- New pattern holds under load.\n"}
|
|
||||||
|
|
||||||
got := Merge(a, b).Content
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(got, "## Key"),
|
|
||||||
"casing drift in the heading must not create a second section")
|
|
||||||
assert.Contains(t, got, "Appetite beats estimation.")
|
|
||||||
assert.Contains(t, got, "New pattern holds under load.",
|
|
||||||
"the differently-cased section's content must still be unioned in")
|
|
||||||
assert.Contains(t, got, "## Key Claims",
|
|
||||||
"the on-disk heading's original casing must be preserved in the output")
|
|
||||||
assert.NotContains(t, got, "## Key claims",
|
|
||||||
"the fresh extraction's heading casing must not overwrite the on-disk display text")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_HeadingWhitespaceDriftMatchesSameSection(t *testing.T) {
|
|
||||||
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions \n\n- Does appetite scale?\n"}
|
|
||||||
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- What about remote teams?\n"}
|
|
||||||
|
|
||||||
got := Merge(a, b).Content
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(got, "## Open Questions"),
|
|
||||||
"surrounding whitespace drift in the heading must not create a second section")
|
|
||||||
assert.Contains(t, got, "Does appetite scale?")
|
|
||||||
assert.Contains(t, got, "What about remote teams?")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_PreExistingCaseDuplicateHeadingsRouteToTheFirstOccurrence(t *testing.T) {
|
|
||||||
// Found in review of PR #130: normalizeHeading's idx map is built by
|
|
||||||
// iterating secsA with idx[normalizeHeading(s.heading)] = i -- last
|
|
||||||
// write wins. Before heading normalization existed, two on-disk
|
|
||||||
// sections differing only by case (real, pre-existing corpus drift --
|
|
||||||
// the exact pattern #106 itself cites) never collided, since idx keyed
|
|
||||||
// on the exact string. After normalization they collide, and every
|
|
||||||
// fresh update silently, deterministically routes to whichever
|
|
||||||
// duplicate happens to appear LAST in the file -- the first one is
|
|
||||||
// frozen forever, never receiving updates again, with nothing to say
|
|
||||||
// so. A Contains-only check doesn't catch this: nothing is literally
|
|
||||||
// deleted, a bullet just lands under the wrong of two still-present
|
|
||||||
// headings. Assert it lands under the FIRST occurrence instead —
|
|
||||||
// deterministic and matches "earliest is canonical," same reasoning
|
|
||||||
// as every other on-disk-wins default in this file.
|
|
||||||
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n\n## KEY CLAIMS\n\n- claim two\n"}
|
|
||||||
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n- fresh claim\n"}
|
|
||||||
|
|
||||||
got := Merge(a, b).Content
|
|
||||||
|
|
||||||
assert.Contains(t, got, "## Key Claims\n\n- claim one\n- fresh claim\n",
|
|
||||||
"the fresh bullet must land in the first-occurrence section, not silently route to the last duplicate")
|
|
||||||
assert.Contains(t, got, "## KEY CLAIMS\n\n- claim two\n",
|
|
||||||
"the second duplicate is untouched by this update, not merged into or overwritten")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeReport_SelfMergeOfPreExistingDuplicateHeadingsReportsNoConflict(t *testing.T) {
|
|
||||||
// Found in review of PR #132: first-occurrence-wins routes ALL of b's
|
|
||||||
// occurrences of a normalized heading to a's FIRST occurrence, not to
|
|
||||||
// their own positional counterpart. So merging a page against itself
|
|
||||||
// (or an echoed-back copy, exactly what #106's revision prompt can
|
|
||||||
// produce) pairs b's second "## NOTES" against a's first "## Notes" —
|
|
||||||
// different content, same normalized key — and MergeReport wrongly
|
|
||||||
// reports a disagreement where none exists. Fixed by pairing
|
|
||||||
// positionally: b's Nth occurrence of a normalized heading matches a's
|
|
||||||
// Nth occurrence, not always the first.
|
|
||||||
page := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
|
|
||||||
|
|
||||||
_, conflicts := MergeReport(page, page)
|
|
||||||
assert.Empty(t, conflicts, "merging a page with pre-existing duplicate headings against itself must report no conflicts")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeReport_DuplicateHeadingsPairPositionallyNotAllToFirst(t *testing.T) {
|
|
||||||
// Companion case: b has genuinely different content in its second
|
|
||||||
// occurrence of the normalized heading — that must compare against a's
|
|
||||||
// second occurrence (and report if they disagree), not against a's
|
|
||||||
// first (which would either false-positive if a's first differs, or
|
|
||||||
// false-negative-mask a real disagreement in the second pair).
|
|
||||||
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
|
|
||||||
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note, revised.\n"}
|
|
||||||
|
|
||||||
_, conflicts := MergeReport(a, b)
|
|
||||||
assert.Equal(t, []string{"NOTES"}, conflicts,
|
|
||||||
"only the second occurrence, which actually disagrees, is reported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
|
|
||||||
// Third review round on PR #126: parseFrontmatterBlock dropped the
|
|
||||||
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
|
|
||||||
// Without it, a comment line containing a colon gets cut at the colon
|
|
||||||
// and re-rendered with collapsed whitespace instead of surviving
|
|
||||||
// verbatim.
|
|
||||||
a := "---\n#see also: https://x/y\ntitle: 'X'\n---\n"
|
|
||||||
b := "---\nsource_hash: bbbb\n---\n"
|
|
||||||
|
|
||||||
got := refreshOwned(a, b)
|
|
||||||
assert.Equal(t, "---\n#see also: https://x/y\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testing.T) {
|
|
||||||
// Same round: 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, regardless of what strings.Cut finds in it. Misparsing
|
|
||||||
// it as a key/value pair and re-rendering strips its indentation,
|
|
||||||
// de-indenting it out of the list it belongs to -- and refreshOwned
|
|
||||||
// re-renders this block on essentially every non-skipped ingest, so this
|
|
||||||
// is the normal path, not an edge case.
|
|
||||||
a := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\n"
|
|
||||||
b := "---\nsource_hash: bbbb\n---\n"
|
|
||||||
|
|
||||||
got := refreshOwned(a, b)
|
|
||||||
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
|
|
||||||
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
|
|
||||||
// real signal: the source now says something different about a section we
|
|
||||||
// already have. Today that disagreement is silently discarded, so nobody
|
|
||||||
// can tell "the source is unchanged" from "the source changed its mind and
|
|
||||||
// we ignored it".
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is fixed, scope is variable.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
Six-week cycles.
|
|
||||||
`}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is negotiable and scope is fixed.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
Six-week cycles.
|
|
||||||
`}
|
|
||||||
|
|
||||||
got, conflicts := MergeReport(existing, fresh)
|
|
||||||
|
|
||||||
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
|
|
||||||
assert.NotContains(t, got.Content, "negotiable")
|
|
||||||
assert.Equal(t, []string{"Summary"}, conflicts,
|
|
||||||
"only the section that actually disagreed is reported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
|
|
||||||
page := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is fixed, scope is variable.
|
|
||||||
`}
|
|
||||||
|
|
||||||
_, conflicts := MergeReport(page, page)
|
|
||||||
assert.Empty(t, conflicts, "identical content is not a disagreement")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMerge_ReportsNothingForInternalWhitespaceVariantsOnly(t *testing.T) {
|
|
||||||
// Found in review of PR #129: the conflict comparison used raw
|
|
||||||
// strings.TrimSpace, which only tolerates LEADING/TRAILING whitespace —
|
|
||||||
// unlike appendNew/mergeBullets, which already canonicalize internal
|
|
||||||
// whitespace runs too (#103: a re-extraction's spacing isn't guaranteed
|
|
||||||
// byte-stable). A mid-line double-space from re-extraction would
|
|
||||||
// false-positive as a "disagreement" here even though every other
|
|
||||||
// whitespace-sensitive comparison in this file already tolerates it.
|
|
||||||
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is fixed, scope is variable.
|
|
||||||
`}
|
|
||||||
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
||||||
title: 'X'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Appetite is fixed, scope is variable.
|
|
||||||
`}
|
|
||||||
|
|
||||||
_, conflicts := MergeReport(existing, fresh)
|
|
||||||
assert.Empty(t, conflicts, "internal whitespace differences alone are not a disagreement")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user