Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f8d6c002 | ||
|
|
dc127a05b5 | ||
|
|
cd61239451 | ||
|
|
31a3dd3e0e | ||
|
|
ae50501ba3 | ||
|
|
9396b4daaa | ||
|
|
0e2bd3d90e | ||
|
|
b149dccdbe | ||
|
|
724a997002 | ||
|
|
59dabc9455 | ||
|
|
6b57a8ba8a | ||
|
|
e1e9a2e30e | ||
|
|
09bde6df8a | ||
|
|
aaaa4b4d64 | ||
|
|
2bb1fef543 | ||
|
|
ee9ac99c40 |
+9
-4
@@ -2,21 +2,26 @@
|
||||
"mcpServers": {
|
||||
"brain": {
|
||||
"type": "http",
|
||||
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||
"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://git-mcp.d-ma.be/mcp",
|
||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||
"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://infra-mcp.d-ma.be/mcp"
|
||||
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
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
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -106,6 +127,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -246,10 +304,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
@@ -319,8 +383,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ name: CD
|
||||
# (Only applies to branch pushes; tag pushes always trigger.)
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
# gitea-mcp#68: without this a PR never got a CI run, so the risk/medium+
|
||||
# PR lane merged on local `task check` alone. Only the check job runs for a
|
||||
# PR -- build and deploy stay push/tag-only (no images, no infra writes,
|
||||
# no secrets from a PR).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: gitea-mcp
|
||||
@@ -44,6 +50,7 @@ jobs:
|
||||
build:
|
||||
name: Build & Import
|
||||
needs: check
|
||||
if: github.event_name == 'push'
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Canonical golangci-lint configuration for the estate's Go repos (infra#309
|
||||
# pattern: one canonical file here, a byte-identical copy in each adopter,
|
||||
# and the drift gate in THIS repo comparing sha256).
|
||||
#
|
||||
# Why this exists: 26 Go repos, 3 had a lint config, and all three were
|
||||
# different. The 23 without one ran golangci-lint's v2 default set, which
|
||||
# does NOT include a formatter -- so gofmt/gofumpt drift went uncaught until
|
||||
# it was found by hand (dma-cli#12: two files drifted, nothing flagged them).
|
||||
#
|
||||
# The two configs this replaces (cobalt-dingo, coo-agent) were near-identical
|
||||
# copies of each other AND both invalid under v2: they carried v1-style
|
||||
# `linters-settings` and `issues.exclude-rules` keys, which `golangci-lint
|
||||
# config verify` rejects outright. Their revive settings and test exclusions
|
||||
# were therefore not in effect. Consolidating fixes that too.
|
||||
#
|
||||
# Per-repo variation does NOT belong here -- this file must stay byte-identical
|
||||
# across adopters so the gate is a checksum, not a diff a human has to judge.
|
||||
# A repo with a genuine exception (agentsquad excludes its seeded-bug fixture
|
||||
# tree, which exists precisely to contain broken code) keeps its own config and
|
||||
# is not listed as an adopter.
|
||||
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck # unchecked errors
|
||||
- govet # suspicious constructs
|
||||
- staticcheck # broad static analysis
|
||||
- unused # unused code
|
||||
- misspell # spelling
|
||||
- revive # idiomatic Go
|
||||
- exhaustive # exhaustive switch on enums
|
||||
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
severity: warning
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# Test files: table-driven cases legitimately carry unused struct fields
|
||||
# and long functions.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- revive
|
||||
- unused
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
@@ -73,6 +73,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
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
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -101,6 +122,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -241,10 +299,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
@@ -314,8 +378,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
@@ -22,15 +23,67 @@ type jwtStatus struct {
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
||||
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
||||
if initErr != nil {
|
||||
status.JWT.LastError = initErr.Error()
|
||||
}
|
||||
body, _ := json.Marshal(status)
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
// readinessFn reports whether JWT validation is currently possible, and why
|
||||
// not. chassisauth.JWTValidator.Ready satisfies it.
|
||||
type readinessFn func(context.Context) (bool, error)
|
||||
|
||||
// newHealthzHandler reports the CURRENT state, evaluated per request.
|
||||
//
|
||||
// It used to marshal one body at startup and serve those bytes forever. That is
|
||||
// how gitea-mcp reported jwt.status=degraded for six days after Authentik had
|
||||
// recovered (mcp-chassis#4): the answer was computed before the IdP was up and
|
||||
// could never change. Fixing the validator alone would not have fixed this —
|
||||
// the validator would recover and /healthz would keep saying degraded.
|
||||
//
|
||||
// Calling ready() per request is also what drives recovery: the chassis resolves
|
||||
// a pending issuer on demand, so every probe is a retry.
|
||||
func newHealthzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
status := healthStatus{OK: true, JWT: jwtStatus{Status: "disabled"}}
|
||||
if dexConfigured {
|
||||
ok, err := ready(r.Context())
|
||||
status.JWT.Status = jwtStatusFor(true, ok)
|
||||
if err != nil {
|
||||
status.JWT.LastError = err.Error()
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
}
|
||||
|
||||
// newReadyzHandler is the kubelet's probe: 503 while JWT auth is degraded.
|
||||
//
|
||||
// /healthz stays 200 deliberately — the process is alive and a liveness probe
|
||||
// must not restart it, because restarting does not make the IdP come back any
|
||||
// sooner. Readiness is the right lever: k8s stops routing OAuth callers to a pod
|
||||
// that would 401 them, and the probe's own cadence becomes the retry loop with
|
||||
// no background goroutine to own.
|
||||
//
|
||||
// A server with no issuer configured is always ready. Making readiness depend on
|
||||
// an issuer it was never given would pull every static-token-only MCP out of its
|
||||
// Service.
|
||||
func newReadyzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !dexConfigured {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
ok, err := ready(r.Context())
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(healthStatus{
|
||||
OK: false,
|
||||
JWT: jwtStatus{Status: "degraded", LastError: msg},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// readinessFunc is what the handler asks. Named here so the test can drive it.
|
||||
type fakeReadiness struct{ calls int }
|
||||
|
||||
// ready reports not-ready on the first call and ready afterwards — the shape of
|
||||
// an IdP that was down when the process booted and came back a minute later.
|
||||
func (f *fakeReadiness) ready(context.Context) (bool, error) {
|
||||
f.calls++
|
||||
if f.calls == 1 {
|
||||
return false, errBoot
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var errBoot = &bootErr{}
|
||||
|
||||
type bootErr struct{}
|
||||
|
||||
func (*bootErr) Error() string { return "oidc discovery: status 503" }
|
||||
|
||||
// A health endpoint that answers from a value captured at startup is not a
|
||||
// health endpoint.
|
||||
//
|
||||
// newHealthzHandler marshalled its body ONCE and served the same bytes for the
|
||||
// process lifetime. On 2026-09-14 gitea-mcp reported jwt.status=degraded for six
|
||||
// days after Authentik recovered, because the answer had been computed before
|
||||
// Authentik was up and could never change. The pod was 1/1 Running and
|
||||
// readiness-green throughout.
|
||||
//
|
||||
// Fixing mcp-chassis#4 alone does not fix this: the validator would recover and
|
||||
// /healthz would keep saying degraded.
|
||||
func TestHealthz_ReflectsCurrentState_NotBootState(t *testing.T) {
|
||||
f := &fakeReadiness{}
|
||||
h := newHealthzHandler(true, f.ready)
|
||||
|
||||
first := decodeHealth(t, h)
|
||||
require.Equal(t, "degraded", first.JWT.Status, "first call: issuer still down")
|
||||
require.NotEmpty(t, first.JWT.LastError, "degraded must say why")
|
||||
|
||||
second := decodeHealth(t, h)
|
||||
require.Equal(t, "enabled", second.JWT.Status,
|
||||
"the issuer recovered; /healthz must notice without a restart")
|
||||
require.Empty(t, second.JWT.LastError)
|
||||
|
||||
require.Equal(t, 2, f.calls, "readiness must be evaluated per request, not cached at startup")
|
||||
}
|
||||
|
||||
// With no issuer configured, JWT auth is disabled by choice and readiness is
|
||||
// never consulted — a static-token-only server must not be reported degraded.
|
||||
func TestHealthz_NoIssuer_IsDisabledNotDegraded(t *testing.T) {
|
||||
f := &fakeReadiness{}
|
||||
h := newHealthzHandler(false, f.ready)
|
||||
|
||||
got := decodeHealth(t, h)
|
||||
require.Equal(t, "disabled", got.JWT.Status)
|
||||
require.Zero(t, f.calls, "no issuer configured means nothing to probe")
|
||||
}
|
||||
|
||||
// readyz is what the kubelet probes. It must FAIL while JWT auth is degraded, so
|
||||
// k8s stops routing to a pod that will 401 every OAuth caller — and so the
|
||||
// probe's own cadence becomes the retry loop.
|
||||
func TestReadyz_FailsWhileDegraded_ThenPasses(t *testing.T) {
|
||||
f := &fakeReadiness{}
|
||||
h := newReadyzHandler(true, f.ready)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code,
|
||||
"a pod that cannot validate any JWT must not be in the Service")
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "recovered without a restart")
|
||||
}
|
||||
|
||||
// A static-token-only server is always ready. Making readyz depend on an issuer
|
||||
// it was never given would take every static-only MCP out of its Service.
|
||||
func TestReadyz_NoIssuer_AlwaysReady(t *testing.T) {
|
||||
f := &fakeReadiness{}
|
||||
h := newReadyzHandler(false, f.ready)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Zero(t, f.calls)
|
||||
}
|
||||
|
||||
func decodeHealth(t *testing.T, h http.HandlerFunc) healthStatus {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
var got healthStatus
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
return got
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -43,7 +44,11 @@ func TestHealthzHandler(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
||||
// Readiness is now a function the handler calls per request rather
|
||||
// than two values captured at startup (mcp-chassis#4). The cases
|
||||
// below are unchanged; only how the state reaches the handler is.
|
||||
ready := func(context.Context) (bool, error) { return tc.validatorReady, tc.initErr }
|
||||
h := newHealthzHandler(tc.dexConfigured, ready)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
|
||||
+16
-16
@@ -54,21 +54,17 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
|
||||
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
|
||||
// startup. Don't let it take down Authentik JWT auth — retry without it.
|
||||
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
|
||||
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
|
||||
for _, ic := range issuers {
|
||||
if ic.IssuerURL != cfg.K8sIssuerURL {
|
||||
pub = append(pub, ic)
|
||||
}
|
||||
}
|
||||
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
|
||||
}
|
||||
if jwtInitErr != nil {
|
||||
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||
// No retry-without-the-k8s-issuer dance any more, and no "JWT auth degraded"
|
||||
// warning to act on. Since mcp-chassis v0.6.0 an unreachable issuer does not
|
||||
// fail construction: each issuer resolves independently and lazily, so one
|
||||
// down issuer degrades only itself and recovers on its own. The fallback this
|
||||
// replaced existed precisely because a single unreachable issuer used to take
|
||||
// the whole validator with it.
|
||||
jwtValidator, err := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||
if err != nil {
|
||||
// Only a configuration error can reach here now.
|
||||
logger.Error("jwt validator config invalid", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||
@@ -98,7 +94,11 @@ func main() {
|
||||
),
|
||||
),
|
||||
))
|
||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||
// Readiness is evaluated per request, and evaluating it is what retries a
|
||||
// pending issuer — so the kubelet's probe cadence is the recovery loop.
|
||||
dexConfigured := cfg.DexIssuerURL != ""
|
||||
mux.Handle("/healthz", newHealthzHandler(dexConfigured, jwtValidator.Ready))
|
||||
mux.Handle("/readyz", newReadyzHandler(dexConfigured, jwtValidator.Ready))
|
||||
if cfg.DexIssuerURL != "" {
|
||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||
|
||||
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0
|
||||
git.d-ma.be/mathias/mcp-chassis v0.7.1
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.7.0 h1:ct31v1F1PMhMCva/V0ErEbgMzwnT+27lYKbug2A/quE=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.7.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -2,9 +2,12 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
)
|
||||
|
||||
@@ -25,11 +28,34 @@ func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Hand
|
||||
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
||||
if hasBearer && token != "" {
|
||||
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
||||
// gitea-mcp#60. This middleware logged nothing at all, so there
|
||||
// was no way to tell a PAT pass-through request from one that
|
||||
// fell through to the shared JWT/static path — only inference
|
||||
// from which failures were absent.
|
||||
//
|
||||
// It matters more here than on the other two paths: this is
|
||||
// where a request acts as a SPECIFIC Gitea user. An audit trail
|
||||
// that cannot say which identity performed a write is not an
|
||||
// audit trail.
|
||||
//
|
||||
// chassisauth.Fingerprint, not a local equivalent: the chassis
|
||||
// emits the same field on its own accepted/rejected lines, and
|
||||
// two different fingerprints for one token cannot be correlated.
|
||||
slog.Info("mcp auth accepted",
|
||||
"auth_path", "passthrough",
|
||||
"caller", login,
|
||||
"token_fp", chassisauth.Fingerprint(token),
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
ctx := withCaller(r.Context(), login)
|
||||
ctx = gitea.WithToken(ctx, token)
|
||||
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
// A token that is not a valid PAT falls through SILENTLY. The
|
||||
// chassis owns the verdict; logging here too would double-count
|
||||
// every static-token request, since all of them reach this
|
||||
// middleware first.
|
||||
}
|
||||
fallback.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubValidator struct{ login string }
|
||||
|
||||
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
|
||||
return s.login, s.login != ""
|
||||
}
|
||||
|
||||
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
|
||||
//
|
||||
// While verifying #59 end to end there was no way to confirm from the server
|
||||
// logs whether calls were using the new PAT pass-through (caller's own Gitea
|
||||
// PAT, own identity) or falling through to the shared JWT/static path. The only
|
||||
// way to reason about it was inference. The chassis names its two paths as of
|
||||
// v0.7.0; this is the third.
|
||||
//
|
||||
// This matters more than the other two: pass-through is the path where the
|
||||
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
|
||||
// identity performed a write is not an audit trail.
|
||||
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
reached := false
|
||||
h := PassthroughMiddleware(stubValidator{login: "alice"},
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
require.True(t, reached)
|
||||
|
||||
var line map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
|
||||
require.Equal(t, "mcp auth accepted", line["msg"])
|
||||
require.Equal(t, "passthrough", line["auth_path"])
|
||||
require.Equal(t, "alice", line["caller"])
|
||||
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
|
||||
|
||||
for k, v := range line {
|
||||
require.NotContains(t, []any{v}, "a-real-gitea-pat",
|
||||
"the PAT must never appear in any field, including %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// A token that is not a valid PAT must fall through silently to the chassis,
|
||||
// which owns the rejection log. Logging here as well would double-count every
|
||||
// static-token request — every one of them reaches this middleware first.
|
||||
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
fellBack := false
|
||||
h := PassthroughMiddleware(stubValidator{},
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-pat")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
require.True(t, fellBack)
|
||||
require.Empty(t, strings.TrimSpace(buf.String()),
|
||||
"the chassis owns the verdict; logging here too would double-count every static-token call")
|
||||
}
|
||||
@@ -27,8 +27,8 @@ func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
|
||||
paths := []string{
|
||||
"/api/v1/repos/mathias//issues", // empty repo
|
||||
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||
"/api/v1/repos/mathias//issues", // empty repo
|
||||
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||
}
|
||||
for _, p := range paths {
|
||||
|
||||
@@ -255,4 +255,3 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ func TestCreateRepo_Org(t *testing.T) {
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||
Name: "infra",
|
||||
Org: "hyperguild",
|
||||
Name: "infra",
|
||||
Org: "hyperguild",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
)
|
||||
|
||||
// auditTargetKeys are the only argument values copied into the audit record:
|
||||
// they identify what a call acted on without copying what it wrote. Every
|
||||
// other argument (file content, issue bodies) is reduced to args_bytes so the
|
||||
// audit trail never becomes a second copy of repo content (gitea-mcp#64).
|
||||
var auditTargetKeys = []string{"owner", "repo", "name", "number", "index", "branch", "path", "tag"}
|
||||
|
||||
// logToolCall emits one structured record per tools/call (OWASP MCP08).
|
||||
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
|
||||
attrs := []any{
|
||||
"tool", name,
|
||||
"caller", auth.Caller(ctx),
|
||||
"args_bytes", len(args),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
}
|
||||
if tok, ok := gitea.TokenFromContext(ctx); ok {
|
||||
attrs = append(attrs, "token_fp", chassisauth.Fingerprint(tok))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal(args, &parsed) == nil {
|
||||
for _, k := range auditTargetKeys {
|
||||
if v, ok := parsed[k]; ok {
|
||||
attrs = append(attrs, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome := "ok"
|
||||
if err != nil {
|
||||
outcome = err.Error()
|
||||
}
|
||||
attrs = append(attrs, "outcome", outcome)
|
||||
slog.InfoContext(ctx, "mcp tool call", attrs...)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type auditStubTool struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s auditStubTool) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{Name: s.name, InputSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
|
||||
func (s auditStubTool) Call(context.Context, json.RawMessage) (json.RawMessage, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
// captureAudit routes slog to a buffer and returns the decoded
|
||||
// "mcp tool call" records emitted while fn runs.
|
||||
func captureAudit(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") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(l), &rec))
|
||||
if rec["msg"] == "mcp tool call" {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func auditServer(tools ...registry.Tool) http.Handler {
|
||||
reg := registry.New()
|
||||
for _, tl := range tools {
|
||||
reg.Register(tl)
|
||||
}
|
||||
srv := mcp.NewServer(mcp.ServerOptions{Registry: reg})
|
||||
return auth.CallerMiddleware(slog.Default(), srv)
|
||||
}
|
||||
|
||||
func callTool(t *testing.T, h http.Handler, name string, args any) {
|
||||
t.Helper()
|
||||
body := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": name, "arguments": args},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Auth-Request-User", "alice")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
// gitea-mcp#64: tools/call dispatched with no record at all.
|
||||
func TestToolsCallLogsOneAuditRecordWithToolCallerAndTarget(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "repo_delete"}), "repo_delete",
|
||||
map[string]any{"owner": "mathias", "repo": "scratch", "confirm": "scratch"})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
r := recs[0]
|
||||
assert.Equal(t, "repo_delete", r["tool"])
|
||||
assert.Equal(t, "alice", r["caller"])
|
||||
assert.Equal(t, "mathias", r["owner"])
|
||||
assert.Equal(t, "scratch", r["repo"])
|
||||
assert.Equal(t, "ok", r["outcome"])
|
||||
assert.Contains(t, r, "duration_ms")
|
||||
}
|
||||
|
||||
func TestToolsCallAuditRecordsErrorOutcome(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "pr_merge", err: errors.New("boom")}),
|
||||
"pr_merge", map[string]any{"owner": "mathias", "repo": "x", "number": 3})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "boom", recs[0]["outcome"])
|
||||
assert.EqualValues(t, 3, recs[0]["number"])
|
||||
}
|
||||
|
||||
// The audit line must not become a second copy of repo content.
|
||||
func TestToolsCallAuditOmitsArgumentValuesOtherThanTarget(t *testing.T) {
|
||||
const sentinel = "SENTINEL-FILE-CONTENT-7f3a"
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "file_write_branch"}), "file_write_branch",
|
||||
map[string]any{"owner": "mathias", "repo": "x", "path": "a.txt", "content": sentinel})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
raw, err := json.Marshal(recs[0])
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(raw), sentinel)
|
||||
assert.Contains(t, recs[0], "args_bytes")
|
||||
}
|
||||
|
||||
// #38 keeps `name` (for repo) and `index` (for number) as legacy aliases; a
|
||||
// caller using them must still leave a target in the audit record.
|
||||
func TestToolsCallAuditRecordsLegacyAliasTargets(t *testing.T) {
|
||||
recs := captureAudit(t, func() {
|
||||
callTool(t, auditServer(auditStubTool{name: "issue_close"}), "issue_close",
|
||||
map[string]any{"owner": "mathias", "name": "infra", "index": 7})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "infra", recs[0]["name"])
|
||||
assert.EqualValues(t, 7, recs[0]["index"])
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolVersion = "2025-03-26"
|
||||
ProtocolVersion = "2025-03-26"
|
||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
@@ -90,7 +91,9 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
NewErrorResponse(req.ID, -32602, "invalid params", nil))
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
|
||||
logToolCall(r.Context(), p.Name, p.Arguments, start, err)
|
||||
if err != nil {
|
||||
code := -32000
|
||||
if errors.Is(err, registry.ErrToolNotFound) {
|
||||
|
||||
@@ -25,6 +25,7 @@ type echoTool struct{}
|
||||
func (echoTool) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
|
||||
}
|
||||
|
||||
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
return args, nil
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
||||
if args.Branch == "" {
|
||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if err := ValidateBranchName(args.Branch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Message == "" {
|
||||
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
||||
const createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
||||
const upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
||||
const getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
||||
const (
|
||||
branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
||||
createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
||||
upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
||||
getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
||||
)
|
||||
|
||||
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// foldKey must agree with strings.EqualFold, which is how encoding/json matches.
|
||||
func TestFoldKeyAgreesWithEqualFold(t *testing.T) {
|
||||
pairs := [][2]string{{"branch", "Branch"}, {"sha", "ſha"}, {"k", "K"}, {"ss", "ß"}, {"a", "b"}, {"Σ", "ς"}}
|
||||
for _, p := range pairs {
|
||||
if got, want := foldKey(p[0]) == foldKey(p[1]), strings.EqualFold(p[0], p[1]); got != want {
|
||||
t.Errorf("%q vs %q: foldKey equal=%v, EqualFold=%v", p[0], p[1], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The duplicate scan is linear in the number of keys: 90k keys (about the
|
||||
// 1 MiB body cap) must scan well under a second.
|
||||
func TestRejectAmbiguousKeysScalesLinearly(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("{")
|
||||
for i := 0; i < 90_000; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `"k%d":1`, i)
|
||||
}
|
||||
b.WriteString("}")
|
||||
var dst struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
start := time.Now()
|
||||
if err := rejectAmbiguousKeys([]byte(b.String()), &dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := time.Since(start); d > 2*time.Second {
|
||||
t.Errorf("scan of 90k keys took %v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// countingServer fails nothing by itself; it records how many requests reached
|
||||
// it so a test can assert that a rejected call never touched Gitea.
|
||||
func countingServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
|
||||
t.Helper()
|
||||
var n atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n.Add(1)
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &n
|
||||
}
|
||||
|
||||
// #65 (1): encoding/json matches keys case-insensitively and the last one wins,
|
||||
// so a client-side check reading `branch` could see a different value from the
|
||||
// one the server acts on. Any two keys equal under strings.EqualFold, and any
|
||||
// key that differs from a schema field only in case, are rejected before a
|
||||
// request is made.
|
||||
func TestParseArgsRejectsCaseVariantKeys(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"issue repro: branch + Branch": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","branch":"feat/y8","Branch":"main"}`,
|
||||
"exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`,
|
||||
"case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`,
|
||||
"nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`,
|
||||
// PR #66 review: a number the token scanner cannot parse as float64 must
|
||||
// not make the scan give up and let the duplicate through.
|
||||
"1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`,
|
||||
"-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`,
|
||||
// encoding/json folds like strings.EqualFold: long s (U+017F) == s.
|
||||
"nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`,
|
||||
}
|
||||
for name, raw := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv, n := countingServer(t)
|
||||
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(raw))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// #65 (2): `refs/heads/main` as a branch makes Gitea create
|
||||
// refs/heads/refs/heads/main, after which every API write to main 500s.
|
||||
// Branch names that can create such refs are rejected before any request.
|
||||
func TestBranchNameValidationRejectsBeforeRequest(t *testing.T) {
|
||||
bad := []string{
|
||||
"refs/heads/main", "refs/tags/v1", "a..b", "feat x", "feat\tx", "feat/",
|
||||
"/feat", "-feat", ".feat", "feat//x", "feat/.x", "feat.lock", "feat.", "HEAD", "feat@{1}", "",
|
||||
}
|
||||
for _, b := range bad {
|
||||
t.Run("file_write_branch "+b, func(t *testing.T) {
|
||||
srv, n := countingServer(t)
|
||||
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||
args, _ := json.Marshal(map[string]string{
|
||||
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
||||
})
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||
})
|
||||
if b == "" {
|
||||
continue // tbd_ship derives a branch when empty
|
||||
}
|
||||
t.Run("tbd_ship "+b, func(t *testing.T) {
|
||||
srv, n := countingServer(t)
|
||||
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||
args, _ := json.Marshal(map[string]string{
|
||||
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
||||
})
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tbd_ship's base is the branch-protection probe key: `refs/heads/main` would
|
||||
// read as unprotected, so it is held to the same rule as the head branch.
|
||||
func TestTBDShipRejectsRefsPrefixedBase(t *testing.T) {
|
||||
srv, n := countingServer(t)
|
||||
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","base":"refs/heads/main"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||
}
|
||||
|
||||
func TestBranchNameValidationAcceptsOrdinaryNames(t *testing.T) {
|
||||
for _, b := range []string{"main", "feat/y8", "tbd/fix-abc-1234abcd", "hold/mail-chat-infra-21", "v1.2_x"} {
|
||||
t.Run(b, func(t *testing.T) {
|
||||
assert.NoError(t, tools.ValidateBranchName(b))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const (
|
||||
|
||||
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "tbd_ship",
|
||||
Name: "tbd_ship",
|
||||
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, reason, branch}.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
@@ -160,6 +160,13 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
if branch == "" {
|
||||
branch = deriveBranch(args.Message, args.Path, args.Content)
|
||||
}
|
||||
// Both names reach a ref: base is also the protection-probe key, so a
|
||||
// `refs/heads/main` base would read as unprotected (#65).
|
||||
for _, b := range []string{base, branch} {
|
||||
if err := ValidateBranchName(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
deleteBranch := true
|
||||
if args.DeleteBranch != nil {
|
||||
deleteBranch = *args.DeleteBranch
|
||||
|
||||
+149
-3
@@ -1,10 +1,14 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
@@ -22,12 +26,154 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
||||
if err := json.Unmarshal([]byte("{}"), dst); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
|
||||
return err
|
||||
} else {
|
||||
// Reject ambiguity BEFORE alias normalisation: re-marshalling through a
|
||||
// map would otherwise silently collapse exact duplicates (#65).
|
||||
if err := rejectAmbiguousKeys(raw, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validateRequiredIdentifiers(dst)
|
||||
}
|
||||
|
||||
// rejectAmbiguousKeys refuses input whose meaning depends on encoding/json's
|
||||
// case-insensitive, last-key-wins matching (#65). A client-side check that reads
|
||||
// `branch` must see the value the server acts on, so: no object at any depth may
|
||||
// carry two keys equal under strings.EqualFold (exact duplicates included), and
|
||||
// no top-level key may differ from a field of dst only in case.
|
||||
func rejectAmbiguousKeys(raw json.RawMessage, dst any) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// UseNumber: without it Token() fails on a number outside float64 range,
|
||||
// which json.Unmarshal ignores in an unknown key — a decoy that would end
|
||||
// the scan before the duplicate (PR #66 review).
|
||||
dec.UseNumber()
|
||||
if err := scanJSONValue(dec); err != nil {
|
||||
if errors.Is(err, gitea.ErrValidation) {
|
||||
return err
|
||||
}
|
||||
// Fail closed: input the scanner cannot read is input it cannot vouch for.
|
||||
return fmt.Errorf("arguments are not valid JSON: %v: %w", err, gitea.ErrValidation)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil // not an object; nothing to compare against the schema
|
||||
}
|
||||
fields := jsonFieldNames(dst)
|
||||
for k := range top {
|
||||
for _, f := range fields {
|
||||
if k != f && strings.EqualFold(k, f) {
|
||||
return fmt.Errorf("argument %q does not match field %q exactly (keys are case-sensitive): %w", k, f, gitea.ErrValidation)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanJSONValue consumes one JSON value from dec, failing with ErrValidation if
|
||||
// any object within it has two keys that are equal under strings.EqualFold.
|
||||
func scanJSONValue(dec *json.Decoder) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch d {
|
||||
case '{':
|
||||
seen := map[string]string{}
|
||||
for dec.More() {
|
||||
kt, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, _ := kt.(string)
|
||||
fk := foldKey(k)
|
||||
if prev, dup := seen[fk]; dup {
|
||||
return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation)
|
||||
}
|
||||
seen[fk] = k
|
||||
if err := scanJSONValue(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for dec.More() {
|
||||
if err := scanJSONValue(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = dec.Token() // closing delimiter
|
||||
return err
|
||||
}
|
||||
|
||||
// foldKey maps every rune to the smallest member of its unicode.SimpleFold
|
||||
// orbit, so foldKey(a) == foldKey(b) exactly when strings.EqualFold(a, b) —
|
||||
// the folding encoding/json uses (long s == s, Kelvin sign == k). A map keyed
|
||||
// on it keeps the duplicate scan linear (PR #66 review: a pairwise EqualFold
|
||||
// loop cost 7s of CPU on ~90k keys).
|
||||
func foldKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
min := r
|
||||
for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
|
||||
if f < min {
|
||||
min = f
|
||||
}
|
||||
}
|
||||
b.WriteRune(min)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonFieldNames returns the json tag names of dst's top-level struct fields.
|
||||
func jsonFieldNames(dst any) []string {
|
||||
v := reflect.ValueOf(dst)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
t := v.Elem().Type()
|
||||
names := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
||||
if name == "" || name == "-" {
|
||||
name = t.Field(i).Name
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// branchNameRe is the shape the skills mcp-destructive-guard hook enforces.
|
||||
var branchNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||
|
||||
// ValidateBranchName rejects branch names that Gitea would turn into a ref
|
||||
// other than refs/heads/<name> as the caller meant it. `refs/heads/main` in
|
||||
// particular creates refs/heads/refs/heads/main, after which every API write to
|
||||
// main fails with "matches more than one" until the ref is deleted by hand
|
||||
// (#65). Beyond the shape regexp it applies git's own check-ref-format rules
|
||||
// that the regexp admits.
|
||||
func ValidateBranchName(b string) error {
|
||||
bad := !branchNameRe.MatchString(b) ||
|
||||
strings.HasPrefix(b, "refs/") ||
|
||||
b == "HEAD" ||
|
||||
strings.Contains(b, "..") ||
|
||||
strings.Contains(b, "//") ||
|
||||
strings.Contains(b, "/.") ||
|
||||
strings.HasSuffix(b, "/") ||
|
||||
strings.HasSuffix(b, ".") ||
|
||||
strings.HasSuffix(b, ".lock")
|
||||
if bad {
|
||||
return fmt.Errorf("invalid branch name %q (want a short name like feat/x, no refs/ prefix): %w", b, gitea.ErrValidation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequiredIdentifiers rejects an empty repo identifier at the tool layer
|
||||
// before it reaches the client, where it would build a trailing-empty path
|
||||
// segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and
|
||||
@@ -36,7 +182,7 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
||||
// allowlist check. Returns a typed ErrValidation naming the missing field.
|
||||
func validateRequiredIdentifiers(dst any) error {
|
||||
v := reflect.ValueOf(dst)
|
||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
|
||||
@@ -52,11 +52,11 @@ func TestWorkflowRunListTool(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status=all is no-op",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
||||
notQuery: []string{"status"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
name: "status=all is no-op",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
||||
notQuery: []string{"status"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "branch filter",
|
||||
|
||||
@@ -58,7 +58,7 @@ if [ "$FORCE" = false ]; then
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||
_token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
|
||||
Reference in New Issue
Block a user