Compare commits

..
6 Commits
Author SHA1 Message Date
mathias 18d01b64d7 chore(context): restore the root rules in AGENTS.md and system-prompt.txt (#200) (#201)
CI / Lint / Test / Vet (push) Successful in 1m0s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-09 06:02:43 +00:00
mathiasandClaude Sonnet 5.5 5dfd2c0f7e docs: main is protected, ship by PR (local-dev#42)
CI / Lint / Test / Vet (push) Successful in 1m28s
CI / Mirror to GitHub (push) Successful in 3s
Gitea will refuse direct pushes to main in this repo. Rule 5's reviewed lane was enforced on the merge tools
(tbd_ship / pr_merge, gitea-mcp v0.17.0/v0.18.0) but not on a direct commit; this overrides rule 5's "risk/low straight
to main" for this repo and says why. Derived adapter files regenerated where the repo has them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:34:50 +02:00
mathiasandClaude Opus 5.5 d6ddac4a03 fix(metrics): label an unmatched request "unmatched", never its raw path (#185)
CI / Mirror to GitHub (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 1m4s
An authenticated REST request to an unregistered path reached the inner
mux, which left Pattern empty, and metrics.Middleware fell back to the
raw URL path: any token holder could create one Prometheus series per
unique path. The fallback is now the fixed label "unmatched"; real routes
keep their pattern. Tests: buildMux with an authenticated random path,
and the middleware unit test; the timing test now routes through a
ServeMux as production does.

Fixes #185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:45 +02:00
mathiasandClaude Opus 5.5 4422dd1f69 test(symlinkguard): synchronise the alert-queue tests on observable completion (#193)
CI / Lint / Test / Vet (push) Successful in 1m3s
CI / Mirror to GitHub (push) Successful in 4s
Three Watch tests flaked under -race and CPU load (3/8 of 8 parallel
x10 runs on main):
- QueueIsBounded slept 100ms before looking for the summary; it now
  waits for the summary, then checks the bound.
- DeliversEveryQueuedAlert asserted FIFO order, but the outage ends
  asynchronously, possibly mid-flush, so a later item can send first. It
  now asserts each removal is delivered exactly once (order is not what
  it is named for).
- AccountsForEveryRemoval slept 50ms after the first summary, counted
  only "removed 1" and took the last summary's count. It now sums every
  "removed N" and every suppression count and waits until they account
  for all plants.

Stress: 0/8 failures in three rounds of 8 parallel x -test.count=10 under
-race. Flips still red: unbounded queue, dropped retries, suppressions
not counted.

Fixes #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:33:55 +02:00
mathiasandClaude Opus 5.5 f65f0b7826 fix(audit): each ntfy alert source sends its own title (#163)
CI / Lint / Test / Vet (push) Successful in 1m13s
CI / Mirror to GitHub (push) Successful in 4s
Every alert was titled "brain-capture audit degraded", including the
symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle
returns a titled copy (nil stays nil); buildNotifier(getenv, title) is
called per source: capture audit (unchanged title), "brain symlink
tripwire", "brain governance edit". Tests assert the Title header.

Fixes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:28:38 +02:00
mathiasandClaude Opus 5.5 f4636c7ef0 fix: creating and generated writes go through the brain root (#188, #158 part A) (#189)
CI / Lint / Test / Vet (push) Failing after 1m2s
CI / Mirror to GitHub (push) Skipped
New brainfs PutRegular (create or replace), CreateRegular (create only), AppendRegular, Remove and RealParent: missing parents created one component at a time through the root, every existing component a real directory, new files created with O_EXCL and confirmed before writing. brain_write, promote/reject (O_EXCL create, source kept on refusal), StampProvenanceFile, BuildWingIndex, RebuildIndex, AppendLog and the tunnel candidate log use them; provenance.ApplyFile removed.

Fixes #188. Refs #158. Adversarial review: BLOCKED, BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:25:54 +00:00
30 changed files with 978 additions and 272 deletions
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+81 -17
View File
@@ -164,6 +164,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -244,25 +301,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## Knowledge base — actively use it
@@ -514,4 +566,16 @@ When acting as a coding agent on this project:
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
---
+81 -17
View File
@@ -159,6 +159,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -239,25 +296,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## Knowledge base — actively use it
@@ -508,3 +560,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+9 -6
View File
@@ -179,9 +179,12 @@ func alertingWarnings(getenv func(string) string) []string {
return out
}
func buildNotifier() audit.Notifier {
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n
// buildNotifier returns the ntfy notifier for one alert source, titled so
// the source is recognisable (hyperguild#163), or nil when ntfy is not
// configured.
func buildNotifier(getenv func(string) string, title string) audit.Notifier {
if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n.WithTitle(title)
}
return nil
}
@@ -203,7 +206,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err)
os.Exit(1)
}
notifier := buildNotifier()
notifier := buildNotifier(os.Getenv, "brain-capture audit degraded")
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -366,7 +369,7 @@ func main() {
ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire"))
// Never fatal on an unreachable issuer (#83): on a cold cluster boot this
// pod can beat dex to serving and get a 502 from ingress, and exiting meant
@@ -546,7 +549,7 @@ func main() {
// capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit"))
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true)
} else {
+13
View File
@@ -159,3 +159,16 @@ func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `path="GET /pending"`)
}
// hyperguild#185: an authenticated request to an unregistered path reached
// the inner REST mux, which left Pattern empty, and the metrics label fell
// back to the raw URL path: one Prometheus series per unique path any token
// holder sends. It is now labelled with a fixed catch-all.
func TestBuildMuxLabelsAnUnregisteredPathWithAFixedLabel(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/zqrandom-185-segment", "Bearer "+routesTok)
require.Equal(t, http.StatusNotFound, code)
_, body := do(t, r, "GET", "/metrics", "")
assert.NotContains(t, body, "zqrandom-185-segment", "the raw path became a metrics label")
assert.Contains(t, body, `path="unmatched"`)
}
@@ -1,6 +1,9 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -74,3 +77,16 @@ func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "classification.yaml")
}
// hyperguild#163: each alert source gets its own ntfy title.
func TestBuildNotifierUsesTheGivenTitle(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Title") }))
defer srv.Close()
env := map[string]string{"BRAIN_NTFY_URL": srv.URL}
n := buildNotifier(func(k string) string { return env[k] }, "brain symlink tripwire")
require.NotNil(t, n)
require.NoError(t, n.Notify(context.Background(), "m"))
assert.Equal(t, "brain symlink tripwire", got)
assert.Nil(t, buildNotifier(func(string) string { return "" }, "x"), "no ntfy URL, no notifier")
}
+14 -5
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
@@ -227,14 +228,22 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
// 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)
// Created through the brain root, never through a symlinked wing or
// hall directory (hyperguild#188).
rel, err := filepath.Rel(brainDir, dest)
if err != nil {
return "", fmt.Errorf("note path: %w", err)
}
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
rel = filepath.ToSlash(rel)
bfs, err := brainfs.Open(brainDir)
if err != nil {
return "", fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
if err := bfs.PutRegular(rel, []byte(final)); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
return rel, nil
}
// scalarValues flattens frontmatter fields to key -> scalar value for
+10 -12
View File
@@ -127,18 +127,18 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
}
fm.set("promoted_at", now)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
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. Both go
// through the brain root (hyperguild#188).
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
// Created with O_EXCL: a note that appeared after the collision check
// above is never overwritten.
if err := createBrainFile(brainDir, dest, []byte(promoted)); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
if err := os.Remove(src); err != nil {
if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
}
@@ -196,14 +196,12 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
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 {
// Write-then-delete: the source survives any write failure. Both go
// through the brain root (hyperguild#188).
if err := createBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil {
return "", fmt.Errorf("write rejected note: %w", err)
}
if err := os.Remove(src); err != nil {
if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
}
@@ -0,0 +1,87 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#188: promote and reject wrote the destination with
// os.MkdirAll + os.WriteFile by path, so a symlinked wing (promote) or a
// symlinked raw/rejected (reject) redirected the note; the raw source was
// then removed.
func TestPromoteAndRejectRefuseASymlinkedDestinationDirectory(t *testing.T) {
brain := t.TempDir()
outside := t.TempDir()
raw := filepath.Join(brain, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "lw")))
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "rejected")))
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
assert.Error(t, err)
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
assert.Error(t, err)
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "a note was written outside the brain")
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
assert.NoError(t, err, "the raw source is kept when the write is refused")
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "w", Hall: "facts"})
require.NoError(t, err, "positive control: promote into a real wing")
}
// StampProvenanceFile read and rewrote the note by path (provenance.ApplyFile).
func TestStampProvenanceFileRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
brain := t.TempDir()
victim := filepath.Join(brain, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
assert.Error(t, api.StampProvenanceFile(brain, "wiki/w/alias/v.md", "static", "x"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "---\ntitle: v\n---\nuntouched\n", string(got))
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nbody\n"), 0o644))
require.NoError(t, api.StampProvenanceFile(brain, "wiki/w/facts/r.md", "static", "x"), "positive control")
got, _ = os.ReadFile(real)
assert.Contains(t, string(got), "written_by: x")
}
// The same through an in-brain symlinked wing and raw/rejected, which
// os.Root alone would follow.
func TestPromoteAndRejectRefuseAnInBrainSymlinkedDestinationDirectory(t *testing.T) {
brain := t.TempDir()
raw := filepath.Join(brain, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
target := filepath.Join(brain, "archive")
require.NoError(t, os.MkdirAll(target, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(brain, "wiki", "lw")))
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(raw, "rejected")))
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
assert.Error(t, err)
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
assert.Error(t, err)
ents, _ := os.ReadDir(target)
assert.Empty(t, ents, "a note was written at the in-brain link's target")
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
assert.NoError(t, err, "the raw source is kept")
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
require.NoError(t, err, "positive control: reject into a real raw/rejected")
}
+57 -3
View File
@@ -1,6 +1,10 @@
package api
import (
"fmt"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
)
@@ -11,11 +15,61 @@ 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.
// StampProvenanceFile applies StampProvenance to a brain-relative note path,
// read and rewritten through the brain root (hyperguild#158).
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
_, abs, err := resolveWithin(brainDir, relPath)
rel, _, err := resolveWithin(brainDir, relPath)
if err != nil {
return err
}
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
b, err := bfs.ReadRegular(rel)
if err != nil {
return fmt.Errorf("read for provenance: %w", err)
}
out, err := provenance.Apply(string(b), provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
if err != nil {
return err
}
if out == string(b) {
return nil
}
if err := bfs.WriteRegular(rel, []byte(out)); err != nil {
return fmt.Errorf("write provenance: %w", err)
}
return nil
}
// createBrainFile creates the file at abs (inside brainDir) through the
// brain root and fails if anything is already there.
func createBrainFile(brainDir, abs string, data []byte) error {
rel, err := filepath.Rel(brainDir, abs)
if err != nil {
return err
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return bfs.CreateRegular(filepath.ToSlash(rel), data)
}
// removeBrainFile removes the file at abs (inside brainDir) through the
// brain root.
func removeBrainFile(brainDir, abs string) error {
rel, err := filepath.Rel(brainDir, abs)
if err != nil {
return err
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return bfs.Remove(filepath.ToSlash(rel))
}
@@ -0,0 +1,43 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#188: brain_write created a new note with os.MkdirAll +
// os.WriteFile by path, so a symlinked wing directory redirected the note
// elsewhere in the brain or outside it.
func TestWriteNoteRefusesASymlinkedWing(t *testing.T) {
for name, target := range map[string]func(t *testing.T, brain string) string{
"in-brain": func(t *testing.T, brain string) string {
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
return filepath.Join("..", "raw")
},
"outside": func(t *testing.T, _ string) string { return t.TempDir() },
} {
t.Run(name, func(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
link := target(t, brain)
require.NoError(t, os.Symlink(link, filepath.Join(brain, "wiki", "lw")))
_, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "lw", Hall: "facts", Filename: "n", Content: "learning"})
assert.Error(t, err)
resolved := link
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(brain, "wiki", link)
}
_, serr := os.Stat(filepath.Join(resolved, "facts"))
assert.True(t, os.IsNotExist(serr), "a hall was created at the link's target")
_, err = api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning"})
require.NoError(t, err, "positive control: a real wing is written")
})
}
}
+20 -1
View File
@@ -13,9 +13,14 @@ import (
type NtfyNotifier struct {
topicURL string
token string
title string
http *http.Client
}
// defaultNtfyTitle is the title of a notifier built without WithTitle: the
// capture audit's degraded-state alert, its first user.
const defaultNtfyTitle = "brain-capture audit degraded"
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
// protected ntfy instances; it is held here and only sent in the
@@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
return &NtfyNotifier{
topicURL: strings.TrimRight(topicURL, "/"),
token: token,
title: defaultNtfyTitle,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// WithTitle returns a copy that sends title as the ntfy Title header, so each
// alert source (the capture audit, the symlink tripwire, governance edits)
// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not
// configured) stays nil.
func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier {
if n == nil {
return nil
}
c := *n
c.title = title
return &c
}
// Notify posts a message to the ntfy topic.
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
if err != nil {
return err
}
req.Header.Set("Title", "brain-capture audit degraded")
req.Header.Set("Title", n.title)
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,brain")
if n.token != "" {
@@ -0,0 +1,29 @@
package audit
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#163: every alert went out titled "brain-capture audit
// degraded", including the symlink tripwire's and governance alerts.
func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Title"))
}))
defer srv.Close()
base := NewNtfyNotifier(srv.URL, "")
require.NoError(t, base.Notify(context.Background(), "m"))
require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m"))
require.NoError(t, base.Notify(context.Background(), "m"))
assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got,
"WithTitle returns a copy; the base keeps its default title")
var nilN *NtfyNotifier
assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil")
}
+3 -5
View File
@@ -7,8 +7,6 @@ import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -32,7 +30,6 @@ func BuildWingIndex(brainDir, wing string) error {
if w == "" {
return fmt.Errorf("invalid wing %q", wing)
}
wingDir := filepath.Join(brainDir, "wiki", w)
// Listed and read through the brain root (hyperguild#157): a wing or
// hall that is a symlink is not followed, and notes are regular files.
bfs, err := brainfs.Open(brainDir)
@@ -63,8 +60,9 @@ func BuildWingIndex(brainDir, wing string) error {
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
}
dest := filepath.Join(wingDir, "_index.md")
return os.WriteFile(dest, []byte(b.String()), 0o644)
// Written through the brain root, never through a symlinked _index.md
// (hyperguild#158).
return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String()))
}
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
+12 -25
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
@@ -176,35 +175,23 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
if len(cs) == 0 {
return nil
}
rawDir := filepath.Join(brainDir, "raw")
if err := os.MkdirAll(rawDir, 0o755); err != nil {
return err
}
stamp := time.Now().UTC().Format("2006-01-02")
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
existed := fileExists(path)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
// Appended through the brain root, never through a symlinked raw/ or
// log file (hyperguild#158).
bfs, err := brainfs.Open(brainDir)
if err != nil {
return err
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = f.Close() }()
if !existed {
if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil {
return err
}
defer func() { _ = bfs.Close() }()
stamp := time.Now().UTC().Format("2006-01-02")
rel := "raw/tunnel-candidates-" + stamp + ".md"
var buf strings.Builder
if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) {
buf.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n")
}
for _, c := range cs {
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
if _, err := f.WriteString(line); err != nil {
return err
}
fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
}
return nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
return bfs.AppendRegular(rel, []byte(buf.String()))
}
// WriteTunnel appends a bidirectional wikilink between sourcePath and
@@ -0,0 +1,52 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// hyperguild#158: BuildWingIndex wrote wiki/<wing>/_index.md with
// os.WriteFile, so a symlinked _index.md was written through.
func TestBuildWingIndexDoesNotWriteThroughASymlinkedIndex(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "n.md"), []byte("---\ntitle: zqn\n---\n"), 0o644))
victim := filepath.Join(b, "raw", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(b, "wiki", "w", "_index.md")))
assert.Error(t, brain.BuildWingIndex(b, "w"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got))
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "w", "_index.md")))
require.NoError(t, brain.BuildWingIndex(b, "w"), "positive control")
idx, _ := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
assert.Contains(t, string(idx), "zqn")
}
// AutoTunnel's fuzzy-candidate log appended to raw/tunnel-candidates-*.md
// with os.OpenFile, so a symlinked raw/ (or log file) was written through.
func TestAutoTunnelDoesNotAppendTheCandidateLogThroughASymlinkedRaw(t *testing.T) {
b := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "a", "facts"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b", "facts"), 0o755))
// "zq widget" matches as a substring of "zq widgets", a fuzzy candidate.
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "b", "facts", "t.md"), []byte("---\ntitle: Zq Widget\n---\n"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(b, "raw")))
_ = brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets")
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "the candidate log was written outside the brain")
require.NoError(t, os.Remove(filepath.Join(b, "raw")))
require.NoError(t, brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets"))
logs, _ := filepath.Glob(filepath.Join(b, "raw", "tunnel-candidates-*.md"))
assert.Len(t, logs, 1, "positive control: the fuzzy candidate is logged in the real raw/")
}
+114 -23
View File
@@ -136,49 +136,140 @@ func (b *Brain) ReadDirDirs(relDir string) ([]string, error) {
return names, nil
}
// OpenLock opens (creating if absent) a regular lock file at a clean
// brain-relative path for flock, through the root (hyperguild#192): every
// directory above it must be real, an existing lock must be a regular file,
// and the opened handle must be the file at the path (os.Root follows an
// in-root final symlink even with O_NOFOLLOW). A dangling or swapped-in
// symlink is refused; nothing is created at its target.
func (b *Brain) OpenLock(rel string) (*os.File, error) {
// PutRegular creates or replaces a regular file at a brain-relative path,
// for creating writes (hyperguild#188, #158). Missing parent directories are
// created one component at a time through the root, and every existing
// component must be a real directory, so a symlinked directory cannot
// redirect the write elsewhere in the brain or outside it. An existing file
// is replaced only if it is regular (WriteRegular's checks); a new file is
// created with O_EXCL and confirmed to be the file that was opened. A
// directory swapped in after the checks is the #167 residual.
func (b *Brain) PutRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return nil, err
return err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
if fi, err := b.root.Lstat(rel); err == nil && !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
if _, err := b.root.Lstat(rel); err == nil {
return b.WriteRegular(rel, data)
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
f, err := b.root.OpenFile(rel, os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
return b.createNew(rel, data)
}
// CreateRegular creates a new regular file at a brain-relative path, with
// PutRegular's parent rules, and fails with fs.ErrExist if anything is
// already there: for writers that must never overwrite (promote, reject).
func (b *Brain) CreateRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
return b.createNew(rel, data)
}
// createNew creates rel with O_EXCL and writes data only after confirming
// the path is the regular file it created.
func (b *Brain) createNew(rel string, data []byte) error {
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil {
return nil, err
return err
}
defer func() { _ = f.Close() }()
hfi, herr := f.Stat()
lfi, lerr := b.root.Lstat(rel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
_ = f.Close()
return nil, fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
return fmt.Errorf("%s: changed while creating: %w", rel, ErrNotRegular)
}
return f, nil
if _, err := f.Write(data); err != nil {
return err
}
return f.Close()
}
// RemoveLock removes the lock file at rel through the root (a symlink there
// is removed itself, never its target).
func (b *Brain) RemoveLock(rel string) error {
// AppendRegular appends data to a regular file at a brain-relative path,
// creating it (and missing parent directories, as PutRegular does) if it is
// absent. It writes only after confirming the path is the regular file it
// opened, so a final symlink or special file, or one swapped in, is refused.
func (b *Brain) AppendRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.realDir(path.Dir(rel)); err != nil {
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
if fi, err := b.root.Lstat(rel); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_APPEND|os.O_CREATE|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
hfi, herr := f.Stat()
lfi, lerr := b.root.Lstat(rel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
}
if _, err := f.Write(data); err != nil {
return err
}
return f.Close()
}
// Remove removes the file at a clean brain-relative path through the root,
// refusing a path behind a symlinked directory. A final symlink is removed
// itself, never its target.
func (b *Brain) Remove(rel string) error {
if err := b.RealParent(rel); err != nil {
return err
}
return b.root.Remove(rel)
}
// RealParent returns ErrNotRegular unless rel is clean and every directory
// above it is a real directory, for writers that do their own I/O (an atomic
// rename) but must not be redirected by a symlinked directory.
func (b *Brain) RealParent(rel string) error {
if err := cleanRel(rel); err != nil {
return err
}
return b.realDir(path.Dir(rel))
}
// mkdirReal makes sure every component of rel exists as a real directory,
// creating missing ones through the root; an existing component that is
// not a real directory is ErrNotRegular.
func (b *Brain) mkdirReal(rel string) error {
if rel == "." {
return nil
}
prefix := ""
for _, part := range strings.Split(rel, "/") {
prefix = path.Join(prefix, part)
fi, err := b.root.Lstat(prefix)
if errors.Is(err, fs.ErrNotExist) {
if err := b.root.Mkdir(prefix, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
fi, err = b.root.Lstat(prefix)
}
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%s: component %s is not a directory: %w", rel, prefix, ErrNotRegular)
}
}
return nil
}
// ReadRegular reads a brain-relative path, refusing anything that is not a
// regular file, both before the open and on the opened handle.
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
+153
View File
@@ -376,3 +376,156 @@ func TestReadAndWriteRegularRefuseAPathThatIsNotClean(t *testing.T) {
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
}
// hyperguild#188: PutRegular creates or replaces a regular file through the
// root. Missing parent directories are created; an existing component that
// is not a real directory, a final symlink or special file, and an unclean
// path are refused, and nothing is created at a link's target.
func TestPutRegularCreatesOrReplacesOnlyThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "wiki", "outwing")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(dir, "wiki", "w", "link.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "wiki", "w", "f.md"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"wiki/inwing/facts/n.md", "wiki/outwing/facts/n.md", "wiki/w/link.md", "wiki/w/f.md", "wiki/w/../../raw/v.md", "/abs.md"} {
assert.ErrorIs(t, b.PutRegular(rel, []byte("x")), ErrNotRegular, rel)
}
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "nothing created outside the brain")
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "one", string(got))
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("two")), "positive control: replace")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "two", string(got))
}
func TestAppendRegularCreatesOrAppendsOnlyThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "outdir")))
require.NoError(t, os.Symlink("raw", filepath.Join(dir, "indir")))
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(dir, "log.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "f.md"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"outdir/l.md", "indir/v.md", "log.md", "f.md", "raw/../log.md"} {
assert.ErrorIs(t, b.AppendRegular(rel, []byte("x")), ErrNotRegular, rel)
}
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
require.NoError(t, b.AppendRegular("logs/a.md", []byte("one\n")), "positive control: create")
require.NoError(t, b.AppendRegular("logs/a.md", []byte("two\n")), "positive control: append")
got, _ = os.ReadFile(filepath.Join(dir, "logs", "a.md"))
assert.Equal(t, "one\ntwo\n", string(got))
}
func TestRealParentRefusesASymlinkedDirectoryOrUncleanPath(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.RealParent("wiki/alias/x.md"), ErrNotRegular)
assert.ErrorIs(t, b.RealParent("wiki/../raw/x.md"), ErrNotRegular)
assert.NoError(t, b.RealParent("wiki/x.md"), "positive control")
}
// cleanRel must refuse an unclean path on its own: here the ".." resolves,
// after a symlinked directory, to a real regular file, so only the clean
// check stands between the call and that file.
func TestAppendRegularRefusesAnUncleanPathThatResolvesToARealFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.AppendRegular("indir/../v.md", []byte("x")), ErrNotRegular)
assert.ErrorIs(t, b.PutRegular("indir/../v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
}
func TestRemoveRefusesAPathBehindASymlinkedDirectoryAndRemovesOnlyTheLink(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("keep"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(dir, "wiki", "link.md")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "n.md"), []byte("n"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.Remove("wiki/alias/v.md"), ErrNotRegular)
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
assert.NoError(t, err, "the file behind the symlinked directory is kept")
require.NoError(t, b.Remove("wiki/link.md"))
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
assert.NoError(t, err, "removing a final symlink never removes its target")
require.NoError(t, b.Remove("wiki/n.md"), "positive control")
_, err = os.Stat(filepath.Join(dir, "wiki", "n.md"))
assert.True(t, os.IsNotExist(err))
}
// CreateRegular is the only write path for promote and reject: it creates
// missing parents through the root, refuses an in-brain symlinked parent and
// an unclean path, and never replaces anything already there (fs.ErrExist,
// content unchanged), including a dangling link.
func TestCreateRegularCreatesOnlyNewFilesThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "exists.md"), []byte("original"), 0o644))
require.NoError(t, os.Symlink("nowhere.md", filepath.Join(dir, "wiki", "dangling.md")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.CreateRegular("wiki/inwing/facts/n.md", []byte("x")), ErrNotRegular)
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
assert.ErrorIs(t, b.CreateRegular("indir/../v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
assert.ErrorIs(t, b.CreateRegular("wiki/exists.md", []byte("x")), fs.ErrExist)
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "exists.md"))
assert.Equal(t, "original", string(got))
assert.ErrorIs(t, b.CreateRegular("wiki/dangling.md", []byte("x")), fs.ErrExist)
_, err = os.Stat(filepath.Join(dir, "wiki", "nowhere.md"))
assert.True(t, os.IsNotExist(err), "nothing created at the dangling link's target")
require.NoError(t, b.CreateRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "one", string(got))
}
@@ -1,61 +0,0 @@
package mcp_test
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
)
// hyperguild#192: brain_edit snapshotted the note with os.ReadFile by path,
// which blocks on a FIFO held open by a writer, and hung the call.
func TestBrainEditReturnsPromptlyOnAFIFONoteWithAWriter(t *testing.T) {
dir := newBrainRepo(t)
fifo := filepath.Join(dir, "wiki", "homelab", "facts", "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
srv := mcp.NewServer(dir, nil, nil, nil)
done := make(chan map[string]any, 1)
go func() {
done <- call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/f.md", "old_str": "a", "new_str": "b"})
}()
select {
case resp := <-done:
assert.Contains(t, resp, "error", "a FIFO note is refused")
case <-time.After(3 * time.Second):
t.Fatal("brain_edit hung on a FIFO note")
}
resp := call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA"})
require.NotContains(t, resp, "error", "positive control: a regular note is edited: %v", resp["error"])
}
// hyperguild#192: the edit lock "<note>.lock" was opened by path with
// O_CREATE, so a planted dangling .lock symlink made a successful edit create
// a file at the link's target, outside the brain or elsewhere in it.
func TestBrainEditDoesNotCreateAFileThroughADanglingLockSymlink(t *testing.T) {
for name, target := range map[string]func(t *testing.T, dir string) string{
"outside": func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "created") },
"in-brain": func(t *testing.T, dir string) string { return filepath.Join(dir, "raw", "created") },
} {
t.Run(name, func(t *testing.T) {
dir := newBrainRepo(t)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
tgt := target(t, dir)
require.NoError(t, os.Symlink(tgt, filepath.Join(dir, "wiki", "homelab", "facts", "n.md.lock")))
srv := mcp.NewServer(dir, nil, nil, nil)
_ = call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA"})
_, err := os.Lstat(tgt)
assert.True(t, os.IsNotExist(err), "a file was created at the lock symlink's target")
})
}
}
+3 -10
View File
@@ -6,8 +6,8 @@ import (
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"log/slog"
"os"
"strings"
"time"
@@ -118,14 +118,7 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
return nil, err
}
// No snapshot, no edit: without one a failed stamp could not be undone.
// Read through the brain root, regular files only (hyperguild#192): a
// FIFO held open by a writer hung os.ReadFile.
bfs, err := brainfs.Open(s.brainDir)
if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err)
}
defer func() { _ = bfs.Close() }()
prior, err := bfs.ReadRegular(target.Rel)
prior, err := os.ReadFile(target.Abs)
if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err)
}
@@ -138,7 +131,7 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
// 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 := bfs.WriteRegular(target.Rel, prior); werr != 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)
+4 -3
View File
@@ -83,8 +83,9 @@ func (r *Registry) Observe(path, status string, d time.Duration) {
// Middleware wraps next, observing every request's duration + status.
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
// falling back to the URL path if no Pattern is set. Pattern keeps
// cardinality bounded (one series per route, not one per unique URL).
// or the fixed label "unmatched" if no Pattern is set: falling back to the
// raw URL path gave any caller one series per unique path (hyperguild#185).
// Pattern keeps cardinality bounded (one series per route).
func (r *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
@@ -92,7 +93,7 @@ func (r *Registry) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(rec, req)
path := req.Pattern
if path == "" {
path = req.URL.Path
path = "unmatched"
}
r.Observe(path, statusClass(rec.code), time.Since(start))
})
+23 -2
View File
@@ -71,11 +71,16 @@ func TestMiddleware_RecordsTiming(t *testing.T) {
t.Parallel()
r := New()
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Routed through a ServeMux, as in production: the mux sets the
// request's Pattern, which is the label (an unrouted request is
// "unmatched", hyperguild#185).
mux := http.NewServeMux()
mux.HandleFunc("/query", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Millisecond)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
}))
})
handler := r.Middleware(mux)
srv := httptest.NewServer(handler)
defer srv.Close()
@@ -117,3 +122,19 @@ func TestStatusRecorder_DefaultsTo200(t *testing.T) {
t.Errorf("code %d, want 200", rec.Code)
}
}
// A request with no matched Pattern is labelled "unmatched", never with its
// raw URL path (unbounded cardinality, hyperguild#185).
func TestMiddleware_LabelsAnUnmatchedRequestWithAFixedLabel(t *testing.T) {
r := New()
h := r.Middleware(http.NotFoundHandler())
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/zqrandom-185-unit", nil))
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if strings.Contains(rec.Body.String(), "zqrandom-185-unit") {
t.Errorf("the raw path became a metrics label:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `path="unmatched"`) {
t.Errorf("no unmatched label:\n%s", rec.Body.String())
}
}
+7 -19
View File
@@ -127,7 +127,7 @@ func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
mu.Lock()
defer mu.Unlock()
unlock, err := flockFile(t)
unlock, err := flockFile(t.Abs)
if err != nil {
return Result{}, err
}
@@ -220,32 +220,20 @@ func lockFor(abs string) *sync.Mutex {
}
// flockFile takes an advisory lock covering other processes sharing the
// hostPath — during a RollingUpdate two ingestion pods do. The lock file is
// opened and removed through the brain root, never through a symlink
// (hyperguild#192): a planted dangling "<note>.lock" link made a
// successful edit create a file at its target.
func flockFile(t Target) (func(), error) {
lockRel := t.Rel + ".lock"
bfs, err := brainfs.Open(t.Brain)
// hostPath — during a RollingUpdate two ingestion pods do.
func flockFile(abs string) (func(), error) {
f, err := os.OpenFile(abs+".lock", os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
}
f, err := bfs.OpenLock(lockRel)
_ = bfs.Close()
if err != nil {
return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
return nil, fmt.Errorf("notes: lock %s: %w", filepath.Base(abs), err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
_ = f.Close()
return nil, fmt.Errorf("notes: flock %s: %w", lockRel, err)
return nil, fmt.Errorf("notes: flock %s: %w", filepath.Base(abs), err)
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
if b, err := brainfs.Open(t.Brain); err == nil {
_ = b.RemoveLock(lockRel)
_ = b.Close()
}
_ = os.Remove(abs + ".lock")
}, nil
}
@@ -0,0 +1,36 @@
package notes
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#188/#173: brain_edit's atomic rename must not be redirected by
// a symlinked directory inside the brain.
func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
b := t.TempDir()
// Edit refuses a tree that is not the sync source before any path
// check, so the test needs a .git to reach the symlink refusal.
require.NoError(t, os.MkdirAll(filepath.Join(b, ".git"), 0o755))
victim := filepath.Join(b, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("old text\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "alias")))
_, err := Edit(b, "wiki/w/alias/v.md", "old text", "new text", ScopeNotes)
assert.Error(t, err)
got, _ := os.ReadFile(victim)
assert.Equal(t, "old text\n", string(got))
real := filepath.Join(b, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("old text\n"), 0o644))
_, err = Edit(b, "wiki/w/facts/r.md", "old text", "new text", ScopeNotes)
require.NoError(t, err, "positive control: a note in a real directory is edited")
got, _ = os.ReadFile(real)
assert.Equal(t, "new text\n", string(got))
}
@@ -28,7 +28,6 @@ import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
@@ -102,25 +101,6 @@ func Apply(content string, s Stamp) (string, error) {
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")
@@ -381,18 +381,19 @@ func TestWatchQueueIsBoundedDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond)
time.Sleep(100 * time.Millisecond)
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
n.mu.Lock()
defer n.mu.Unlock()
found := false
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
found = true
// Synchronise on the summary being delivered (hyperguild#193: a fixed
// sleep flaked under load); once it is, the queue has been flushed.
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
return true
}
}
}
assert.True(t, found, "suppressed alerts are counted in a summary")
return false
}, 10*time.Second, 5*time.Millisecond, "suppressed alerts are counted in a summary")
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
}
// Within the bound, every alert queued during an outage is delivered after
@@ -411,11 +412,21 @@ func TestWatchDeliversEveryQueuedAlertAfterAShortOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond)
require.Eventually(t, func() bool { return n.count() >= 3 }, 10*time.Second, 5*time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
for i, name := range []string{"qa", "qb", "qc"} {
assert.Contains(t, n.msgs[i], strconv.Quote(name))
// Each removal delivered exactly once, in any order: the outage ends
// asynchronously, possibly mid-flush, so a later item can send before an
// earlier one (hyperguild#193). Order is not what this test is about.
assert.Len(t, n.msgs, 3)
for _, name := range []string{"qa", "qb", "qc"} {
hits := 0
for _, m := range n.msgs {
if strings.Contains(m, strconv.Quote(name)) {
hits++
}
}
assert.Equal(t, 1, hits, "%s delivered exactly once", name)
}
}
@@ -457,29 +468,30 @@ func TestWatchAccountsForEveryRemovalDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool {
// Synchronise on the record being complete, not on a fixed sleep after
// the first summary (hyperguild#193): sum every "removed N" alert and
// every suppression count until they account for all plants.
account := func() (delivered, counted int) {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
return true
var k int
if i := strings.Index(m, "removed "); i >= 0 && strings.Contains(m, "symlink(s)") {
if _, err := fmt.Sscanf(m[i+len("removed "):], "%d", &k); err == nil {
delivered += k
}
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
if _, err := fmt.Sscanf(m[i+3:], "%d", &k); err == nil {
counted += k
}
}
}
return false
}, 2*time.Second, 5*time.Millisecond)
time.Sleep(50 * time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
delivered, counted := 0, 0
for _, m := range n.msgs {
if strings.Contains(m, "removed 1 symlink(s)") {
delivered++
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted)
}
return delivered, counted
}
assert.Equal(t, plants, delivered+counted, "delivered %d + counted %d must equal %d removals", delivered, counted, plants)
require.Eventually(t, func() bool { d, c := account(); return d+c >= plants }, 10*time.Second, 5*time.Millisecond)
d, c := account()
assert.Equal(t, plants, d+c, "delivered %d + counted %d must equal %d removals", d, c, plants)
}
// A poison message is still dropped (others send while it fails), and the
+2 -4
View File
@@ -3,8 +3,6 @@ package wiki
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
@@ -50,8 +48,8 @@ func RebuildIndex(brainDir, date string) error {
sb.WriteString("\n")
}
dest := filepath.Join(brainDir, "wiki", "index.md")
return os.WriteFile(dest, []byte(sb.String()), 0o644)
// Written through the brain root, never through a symlink (#158).
return bfs.PutRegular("wiki/index.md", []byte(sb.String()))
}
// pageFirstSentence reads the page through the brain root, regular files
+7 -8
View File
@@ -3,8 +3,7 @@ package wiki
import (
"fmt"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"strings"
)
@@ -27,14 +26,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e
}
sb.WriteString("\n")
logPath := filepath.Join(brainDir, "log.md")
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
// Appended through the brain root, never through a symlink (#158).
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open log: %w", err)
return fmt.Errorf("open brain: %w", err)
}
if _, err = f.WriteString(sb.String()); err != nil {
f.Close() //nolint:errcheck
defer func() { _ = bfs.Close() }()
if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil {
return fmt.Errorf("write log: %w", err)
}
return f.Close()
return nil
}
@@ -0,0 +1,35 @@
package wiki
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#158: RebuildIndex wrote wiki/index.md and AppendLog appended
// log.md with os.WriteFile / os.OpenFile, so a symlinked file was written
// through.
func TestRebuildIndexAndAppendLogDoNotWriteThroughSymlinks(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "concepts"), 0o755))
victim := filepath.Join(b, "raw", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(b, "wiki", "index.md")))
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(b, "log.md")))
assert.Error(t, RebuildIndex(b, "2026-10-05"))
assert.Error(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got))
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "index.md")))
require.NoError(t, os.Remove(filepath.Join(b, "log.md")))
require.NoError(t, RebuildIndex(b, "2026-10-05"), "positive control")
require.NoError(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"), "positive control")
l, _ := os.ReadFile(filepath.Join(b, "log.md"))
assert.Contains(t, string(l), "src")
}