Compare commits

..
17 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 e1e9a2e30e feat(auth): every accepted request names the path that granted it
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 16s
CD / Build & Import (push) Successful in 16s
Closes #60. Closes #58 (fixed in mcp-chassis v0.7.0, consumed here).

Three auth paths reach this server and none of them said so on success.

PassthroughMiddleware logged nothing at all, success or failure. While verifying
#59 end to end there was no way to confirm from the logs whether calls were
using the new PAT pass-through — the caller's own Gitea PAT, the caller's own
identity — or falling through to the shared JWT/static path. The only available
reasoning was inference from which failures were absent.

    INFO mcp auth accepted auth_path=passthrough caller=alice token_fp=...
    INFO mcp auth accepted auth_path=jwt        caller=mathiasbq token_fp=...
    INFO mcp auth accepted auth_path=static     token_fp=...

The first line matters more than the other two. Pass-through is the path 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 token_fp on
its own accepted and rejected lines, and two different fingerprints for the same
token cannot be correlated — which is the only reason the field exists. Exported
from the chassis in v0.7.1 for exactly this.

A token that is not a valid PAT still falls through SILENTLY. The chassis owns
the verdict, and logging here as well would double-count every static-token
request, since all of them reach this middleware first. Asserted, because "logs
nothing on the failure path" is indistinguishable from "was never called".

Also lands #58 by consuming mcp-chassis v0.7.0: a JWT presented to a server with
no JWT auth configured now reports jwt_auth_not_configured rather than
static_token_mismatch. That log line is what sent a debugging session into
re-running the OAuth flow while the credentials were valid the whole time.

Verified: task check EXIT=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:59:04 +02:00
mathiasandClaude Opus 5 09bde6df8a fix(health): /healthz answers now, and /readyz takes a degraded pod out
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 30s
CD / Build & Import (push) Successful in 15s
Closes #57. Consumes mcp-chassis v0.6.0 (mcp-chassis#4).

Two halves of one six-day outage. The chassis fix alone would not have fixed
this one.

/HEALTHZ WAS A CONSTANT

newHealthzHandler marshalled its body ONCE at startup and served those bytes for
the process lifetime. On 2026-09-14 it reported jwt.status=degraded for six days
after Authentik had recovered, because the answer was computed before Authentik
was up and could never change. A health endpoint that cannot change its answer
is not a health endpoint.

It now evaluates readiness per request. That is also what drives recovery: the
chassis resolves a pending issuer on demand, so every probe is a retry.

/READYZ IS NEW, AND IT IS THE ACTUAL CONTROL

Through all three occurrences the pod was 1/1 Running and readiness-green while
401ing every OAuth caller. k8s had no signal and kept routing to it. /readyz
answers 503 while JWT auth is degraded, so the pod leaves the Service instead of
serving guaranteed failures, and the kubelet's probe cadence becomes the retry
loop — no background goroutine, no context to own.

/healthz deliberately stays 200. The process is alive; a liveness probe must not
restart it, because restarting does not make the IdP come back sooner.

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 — asserted, not assumed.

THE RETRY-WITHOUT-THE-K8S-ISSUER FALLBACK IS GONE

It existed because one unreachable issuer used to fail the whole constructor and
take Authentik auth down with it. Since v0.6.0 each issuer resolves lazily and
independently, so a down issuer degrades only itself. The warning it logged —
"multi-issuer init failed; retrying without the k8s issuer" — was the first line
of today's incident; it no longer has a reason to exist.

A constructor error now means a config error, which exits non-zero rather than
warning and continuing.

TESTS

healthz_live_test.go, written first and red (newReadyzHandler undefined, and
newHealthzHandler's signature could not express a state that changes). The
sharpest assertion is that readiness is evaluated TWICE for two requests — a
handler that caches cannot pass it.

The existing table test is ported, not weakened: same three cases, same expected
statuses, only the mechanism by which state reaches the handler changed.

Verified: task check EXIT=0 (lint, vet, race). The lint failure it surfaced was
pre-existing and is fixed in the preceding commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:06:15 +02:00
mathiasandClaude Opus 5 aaaa4b4d64 fix(lint): reflect.Ptr -> reflect.Pointer, unblocking task check
`task check` has been red at HEAD since 2bb1fef adopted the canonical
.golangci.yml from infra/ci-templates, which enables govet's `inline` analyzer.
Nothing to do with the change it blocks; reproduced against a clean HEAD before
touching it.

reflect.Ptr is the pre-Go-1.18 spelling of reflect.Pointer and is a deprecated
alias for it. Identical constant, no behaviour change.

Separate commit because it is unrelated to the auth work that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:06:00 +02:00
mathias 2bb1fef543 chore(lint): adopt the canonical .golangci.yml from infra/ci-templates
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 5s
CD / Deploy via GitOps (push) Successful in 4s
Canonical lives at infra ci-templates/golangci.yml, distributed by the
infra#309 pattern: byte-identical copies, checksum drift gate in infra. Do
not edit this file here -- edit the canonical and run `task ci:sync`.

Verified locally before pushing, not pushed-and-hoped: golangci-lint v2.11.4
against a Go 1.26.1 toolchain (matching CI) reports 0 issues here.

Also applies gofumpt, which this config enables and the golangci-lint v2
default set does not check at all. That gap is why the drift was invisible:
earlier passes ran gofmt, which is weaker. Mechanical, deterministic,
build+vet verified.
2026-09-05 14:42:59 +02:00
mathias ee9ac99c40 style: apply gofmt
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 4s
CD / Deploy via GitOps (push) Successful in 4s
Estate-wide consolidation pass. golangci-lint v2 default set enables NO
formatter, so gofmt drift accumulated silently across the estate -- 8 repos
were carrying it, this one included. Same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofmt -w`, nothing hand-edited. Verified
before commit -- gofmt -l now reports 0, go build and go vet both pass.

Clears this repo to adopt the canonical .golangci.yml (infra
ci-templates/golangci.yml, infra#309 pattern) in a later wave.
2026-09-05 12:40:34 +02:00
mathias ef7ccc8295 fix(lint): check resp.Body.Close()'s error (errcheck)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 6s
Caught by CI, not locally -- this sandbox's golangci-lint crashes on any
repo, a pre-existing environment issue unrelated to this code (documented
in dispatch's own session history). gitea-mcp's actual CI run has a
working linter and correctly flagged this.
2026-09-05 11:27:22 +02:00
mathias 4d6597639e feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
Gitea's own token-creation and admin-user endpoints reject token auth
outright -- POST /users/{u}/tokens returns 'auth required', POST
/admin/users returns 'token does not have at least one of required
scope(s), required=[write:admin]' -- for ANY existing token, by design. An
existing token cannot mint another token or create an account; the only
way through either endpoint is real basic auth as an actual site admin.
That is exactly what a sandboxed agent session cannot safely hold.

internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable
against a fake transport (5 tests, no network). Given real admin
username/password, it creates the target account (or resets its password
if it already exists -- deliberately unconditional, gated behind a
required ConfirmServiceAccount flag since that reset is only safe for a
dedicated bot account), mints it a scoped token by authenticating AS that
account (Gitea requires this specifically -- an admin cannot mint on
another account's behalf), grants it read-only collaborator access on
named repos, and stores the result via a SecretStore -- never returning
the token or either password to the caller.

internal/localtools: the MCP tool wrapper (mint_service_account_token) and
the real op-CLI-backed SecretStore (token passed via stdin, never argv,
per the estate's own secret-handling rule). Deliberately its OWN package,
not internal/tools -- this capability is far too dangerous to risk it ever
being wired into RegisterAll and reachable from the shared HTTP gitea-mcp
server, even behind OIDC.

internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line,
standard MCP stdio framing) for the existing registry.Tool interface --
the only transport gitea-mcp had before this was HTTP (server.go), which a
sandboxed session can reach but a real admin password never should be
piped through.

cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code
session (.mcp.json stdio server) or run by hand, via 'op run --env-file
~/.op-env' so admin credentials arrive just-in-time and never touch a
sandboxed session.

11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the
shared HTTP server) changed.
2026-09-05 11:25:41 +02:00
mathias 419ae19cdb fix(context): sync to the estate canonical context-sync.sh, drop Cursor/Aider
CD / Lint / Test / Vet (push) Successful in 24s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 5s
This repo's copy of context-sync.sh was two days stale against the version
Mathias's Cursor/Aider decision (2026-09-01) already landed in dma-cli:
still generated .cursorrules and .aider.conventions.md, and lacked the
--force/placeholder pre-flight and .context/mcp.json root-merge dma-cli's
copy has had since.

Found while building the estate-wide context-sync drift gate (ADR-0019) --
this is exactly the drift the gate exists to catch, found by hand while
establishing the baseline it will check going forward.

- script synced to the canonical hash (dma-cli's current copy)
- .cursorrules, .aider.conventions.md, .aider.conf.yml deleted
- Taskfile's drift assertion and file list narrowed to match
- AGENTS.md, .context/system-prompt.txt regenerated against canonical

task check -> exit 0.
2026-09-05 08:01:07 +02:00
mathiasandClaude Opus 5 e4d07c106c ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 9s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:23 +02:00
mathiasandClaude Opus 5 1d75bf9b25 fix(ci): no expression syntax in a run: block's comments
CD / Lint / Test / Vet (push) Successful in 10s
CD / Deploy via GitOps (push) Successful in 4s
CD / Build & Import (push) Successful in 5s
act interpolates a `run:` block INCLUDING its shell comments, so prose
containing Actions expression syntax is evaluated as an expression. A
comment I added yesterday explaining why a fallback was computed in shell
contained that syntax as an example; act tried to evaluate it, failed
with "Unavailable context: a", and the step then wrote NO outputs at all.

In gitea-mcp that emptied `needs.build.outputs.image-tag`, so its deploy
job committed `image: localhost:5000/gitea-mcp:` to the infra repo --
which is not valid YAML, so Flux's apps Kustomization went Ready:False
and NOTHING in k3s/apps reconciled estate-wide until infra 51ceb7c.

The build still went green throughout, because the shared build-image.sh
falls back to `git rev-parse --short HEAD` when SHA_TAG is empty. A
robust default hid a broken input -- worth remembering as a pattern.

Same comment text removed here; the explanation stays, the syntax does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 15:29:40 +02:00
mathiasandClaude Opus 5 417ab60707 ci: call the shared build-image script instead of an inline copy (infra#309)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Failing after 1m2s
Wave 2. The buildctl/skopeo step here was one of nineteen near-identical
copies; the implementation now lives in .gitea/build-image.sh,
byte-identical to mathias/infra's ci-templates/build-image.sh and held
there by a drift gate. Labels, tags and build-args are inputs.

Behaviour unchanged: same registry, same :sha/:latest/version tags, same
revision and source labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 12:35:52 +02:00
mathiasandClaude Opus 5 f6446c2515 ci: drop the k3s ctr smoke test, impossible on the containerized runner
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 6s
The step ran `sudo k3s ctr images pull` / `ctr run` against the host's
containerd. That worked when the runner was the Arch host; since it was
containerized (infra#233) the image has no sudo, no ctr and no containerd
socket, so the step fails with `sudo: command not found`. It only
surfaced now because the buildah wall (infra#241) killed the job before
this step ever ran.

The deploy job's `Verify rollout` and `Confirm pod running new image`
assert the same property against the real workload rather than a 5s
throwaway container, which is why tapir dropped its smoke test under the
same issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:54:55 +02:00
mathiasandClaude Opus 5 50c447f673 ci(cd): build with buildctl instead of rootless buildah (infra#241)
CD / Build & Import (push) Failing after 14s
CD / Deploy via GitOps (push) Skipped
CD / Lint / Test / Vet (push) Successful in 27s
Rootless buildah in the containerized gitea-runner pod cannot remap
UID/GID on a fresh base-image pull and has no OCI runtime, so this
Dockerfile's `RUN apk add` / `RUN go build` cannot execute there at all.
The last green run here (2026-07-22) predates the runner's
containerization (infra#233, 2026-07-29), so the build has been latently
broken since.

buildctl hands the build to koala's host buildkitd over the socket the
pod already mounts; skopeo pushes the resulting OCI archive once per tag,
since buildctl has no `buildah push` equivalent. The VERSION build-arg
carries over as `--opt build-arg:VERSION=`, so the ldflags version stamp
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:49:47 +02:00
mathiasandClaude Sonnet 5 43714047be fix(auth): owner allowlist trusts pass-through-authenticated callers (#59)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 25s
CD / Deploy via GitOps (push) Has been skipped
Allowlist.Check now takes ctx: when the caller authenticated with
their own Gitea PAT (pass-through, v0.12.0), it skips the static
GITEA_MCP_ALLOWED_OWNERS check entirely — Gitea's own permission
model already gates that caller's access more precisely than a coarse
owner-name list can. The static list still applies unchanged for the
shared static-token/JWT path, where it's the only defense against the
service token's blast radius.

Mechanical: every tool call site already had ctx in scope, so this is
a signature-only change at 41 call sites, no other tool behavior
changes. Closes the "Deferred" item from #59.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:31:43 +02:00
mathiasandClaude Sonnet 5 5601927dc8 feat(auth): per-caller Gitea PAT pass-through (#59)
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 26s
CD / Deploy via GitOps (push) Has been skipped
Replaces the shared GITEA_MCP_DEFAULT_TOKEN for all callers. When a
request's bearer validates directly against Gitea's own /api/v1/user,
that token is used for every upstream call this request makes instead
of the service PAT, and the caller identity comes from Gitea's own
login rather than the proxy header. Any other bearer (static token,
JWT, none) falls through unchanged to the existing chassis auth.

Prep: Authentik now SSOs into Gitea (infra a32801c), so each real user
can mint their own PAT from their own linked account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:22:50 +02:00
mathiasandClaude Opus 4.8 a16c5b0537 fix(code_search): replace the fantasy REST endpoint with a real client-side grep
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
code_search was calling GET /api/v1/repos/{owner}/{repo}/search?type=code — an
endpoint that does not exist. Confirmed against a live Gitea 1.25.5 instance's
swagger spec: only /repos/search, /repos/issues/search, /topics/search etc are
real. Gitea's own web UI code search falls back to server-side `git grep`
because no Repository Indexer is enabled here, and that fallback is an
HTML-only route, not JSON API — so no REST endpoint exists to call, working or
not. Every call to code_search 404'd on the real server.

This went undetected because the existing tests mocked the fantasy endpoint
directly (asserting the request path was .../search?type=code and handing back
a canned JSON envelope) — a textbook case of tests validating a fake instead of
the real system, giving false confidence the tool worked.

SearchCode now does the search itself: resolves the default branch, walks the
tree (GetTree), and substring-matches q (case-insensitive, literal — not a
regex, to keep behavior predictable and avoid a ReDoS surface from
user-supplied input) against fetched file contents (GetFileContents) — the same
approach Gitea's own indexer-less fallback uses, just client-side. Bounded by:
- codeSearchMaxFiles (2000) — files scanned per call
- codeSearchMaxFileSize (512KB, checked via the tree listing's own Size field,
  before any fetch) — skip large blobs
- a binary-extension denylist checked before fetch, plus a null-byte content
  check after fetch, for extensions the denylist misses

Pagination is over the full sorted result set, recomputed each call (no
server-side index to page through incrementally) — acceptable for the repo
sizes this targets; documented as a known limitation, not a hidden footgun.

The tool layer (internal/tools/code_search.go) is UNCHANGED — SearchCode's
signature and the []CodeSearchHit contract are identical, so this is fully
isolated to the client layer + its tests.

Tests: match found in tree, case-insensitive matching, binary extension
skipped WITHOUT fetching (asserted via the fake's fetch log, not just absent
from results), oversized file skipped without fetching, null-byte content
skipped after fetching, pagination across a full sorted set, empty-query
validation — plus the tool-layer single-repo and fan-out tests rewritten
against the same real endpoints (GetRepo/GetTree/GetFileContents +
ListRepos), replacing their fantasy-endpoint fakes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:29:05 +02:00
mathiasandClaude Opus 4.8 1eceb5f7fe ci(cd): skip docs-only pushes; move HOW_GITEA_MCP_WORKS into docs/
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
Relocate the doc under docs/ and add paths-ignore: ['docs/**'] to the CD push
trigger so documentation-only changes no longer rebuild the image and roll the
pod. Branch pushes with only docs/ changes are skipped; tag (v*) pushes and any
push touching code still deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:43:46 +02:00
87 changed files with 2651 additions and 1088 deletions
-2
View File
@@ -1,2 +0,0 @@
read: .aider.conventions.md
auto-commits: false
-386
View File
@@ -1,386 +0,0 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
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
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| 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 |
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
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **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":"..."}` |
| **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`.
- **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
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
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.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:**
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+9 -4
View File
@@ -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}"
}
}
}
}
+90 -21
View File
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
collisions between parallel sessions: two were not branching problems at all — a
generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
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:
@@ -78,6 +94,26 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
## Default stack
| Layer | Default | Fallback | Last resort |
@@ -88,7 +124,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -97,7 +133,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
@@ -135,10 +176,10 @@ Three machines on Tailscale:
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh
## Project landscape
@@ -234,10 +275,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
re-add adapters for them:
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -249,15 +291,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
2. On any other host, `git pull` brings both. Which file a harness reads does
NOT split cleanly by lookup strategy, so do not reason from the strategy:
- `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
@@ -268,7 +324,20 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:**
-389
View File
@@ -1,389 +0,0 @@
# Cursor rules — auto-generated
# Do not edit. Run: task context:sync
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
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
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| 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 |
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
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **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":"..."}` |
| **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`.
- **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
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
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.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:**
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Build an OCI image with buildctl and push it to the local registry.
#
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
# edit here, re-sync, and the gate will tell you which repos still disagree.
#
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
# verifications, and three separate rediscoveries of the same fix in the two
# repo templates and the gitea-ci skill. The differences between the copies were
# incidental -- a label here, a build-arg there -- never intentional. Those are
# inputs now, not forks of the implementation.
#
# Why buildctl and not buildah: the gitea-runner pod runs with
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
# client -- it hands the build to the host's buildkitd over a socket, so the
# privileged layer work happens on the host and this pod's securityContext is
# not in the picture. buildah is absent from the runner image as of baked-v14
# and its absence is asserted at pod start (infra#308).
#
# Inputs, all via environment:
#
# IMAGE (required) image name, e.g. cobalt-dingo
# REGISTRY default localhost:5000
# SHA_TAG default `git rev-parse --short HEAD`
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
# PUSH_LATEST default 1; set 0 to skip the :latest tag
# CONTEXT default . -- build context directory
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
# TARGET optional, multi-stage build target (--opt target=)
# BUILD_ARGS optional, space-separated K=V pairs
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
# writes the secret to PATH itself. A secret VALUE must
# never be passed here: it would land in this script's
# argv and therefore in the CI log.
# EXTRA_LABELS optional, space-separated K=V pairs
# GIT_SHA optional, stamped as org.opencontainers.image.revision
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
#
# Note on shell style below: every conditional is a full `if` block, never
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
# test is false, which is a real bug this estate has already shipped once.
set -euo pipefail
if [ -z "${IMAGE:-}" ]; then
echo "build-image: IMAGE is required" >&2
exit 2
fi
REGISTRY="${REGISTRY:-localhost:5000}"
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
CONTEXT="${CONTEXT:-.}"
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
PUSH_LATEST="${PUSH_LATEST:-1}"
VERSION_TAG="${VERSION_TAG:-}"
if [ -z "${SHA_TAG:-}" ]; then
SHA_TAG="$(git rev-parse --short HEAD)"
fi
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
TAR_FILE="$(mktemp)"
trap 'rm -f "$TAR_FILE"' EXIT
args=(
--frontend dockerfile.v0
--local "context=${CONTEXT}"
--local "dockerfile=${DOCKERFILE_DIR}"
)
if [ -n "${DOCKERFILE_NAME:-}" ]; then
args+=(--opt "filename=${DOCKERFILE_NAME}")
fi
if [ -n "${TARGET:-}" ]; then
args+=(--opt "target=${TARGET}")
fi
for spec in ${BUILD_SECRETS:-}; do
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
args+=(--secret "${spec}")
done
if [ -n "${GIT_SHA:-}" ]; then
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
fi
if [ -n "${IMAGE_SOURCE:-}" ]; then
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
fi
for kv in ${BUILD_ARGS:-}; do
args+=(--opt "build-arg:${kv}")
done
for kv in ${EXTRA_LABELS:-}; do
args+=(--opt "label:${kv}")
done
echo "build-image: building ${REF}"
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
# the OCI archive up -- once per tag, from the same archive, so every tag points
# at bit-identical content.
push_tag() {
echo "build-image: pushing ${1}"
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
}
push_tag "$REF"
if [ "$PUSH_LATEST" = "1" ]; then
push_tag "${REGISTRY}/${IMAGE}:latest"
fi
if [ -n "$VERSION_TAG" ]; then
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
fi
echo "✓ build-image: ${REF} pushed"
+43 -31
View File
@@ -4,6 +4,10 @@ name: CD
push:
branches: [main]
tags: ["v*"]
# Docs-only pushes don't change the image — skip the build/deploy roll.
# (Only applies to branch pushes; tag pushes always trigger.)
paths-ignore:
- 'docs/**'
env:
IMAGE: gitea-mcp
@@ -54,41 +58,42 @@ jobs:
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "version=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
else
# The ldflags version stamp: git tag on a v* build, else short
# sha. Computed in shell rather than as a fallback expression in
# YAML, so it cannot depend on how the runner treats an empty step
# output -- a wrong VERSION would be baked into the binary silently.
#
# NOTE: no expression syntax in this comment, deliberately. act
# interpolates a run: block INCLUDING its comments, so prose
# containing that syntax is evaluated as an expression -- it failed
# with "Unavailable context: a", the step wrote no outputs, and the
# deploy job committed an empty image tag that broke Flux
# estate-wide (infra 51ceb7c).
echo "version=${SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push to local registry
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
# Stamp the real build version: the git tag on a v* build, else the short sha.
VERSION="${{ steps.meta.outputs.version-tag }}"
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REF} \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "✓ Image pushed to ${REF}"
# infra#309: implementation lives in .gitea/build-image.sh, a
# byte-identical copy of mathias/infra's ci-templates/build-image.sh.
# Do not edit it here -- edit the canonical file, run `task ci:sync`,
# and infra's drift gate names any repo still carrying an old copy.
env:
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
GIT_SHA: ${{ github.sha }}
IMAGE_SOURCE: ${{ github.repositoryUrl }}
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
run: bash .gitea/build-image.sh
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
echo "$OUTPUT" | grep -q "gitea-mcp" \
&& echo "✓ Smoke test passed" \
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
# No smoke test here. The old one ran `sudo k3s ctr run` against the host's
# containerd: written for the Arch host runner, impossible in the
# containerized runner (infra#233) which has no sudo, no ctr and no
# containerd socket -- it failed with `sudo: command not found` the moment
# infra#241's buildctl migration let the build reach it. The deploy job's
# `Verify rollout` + `Confirm pod running new image` cover the same ground
# more strongly, which is why tapir dropped its smoke test too (infra#241).
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
deploy:
@@ -104,6 +109,13 @@ jobs:
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# infra#311: fail on an empty tag BEFORE patching anything. The
# grep verification further down CANNOT catch it -- with IMAGE_TAG
# empty its pattern matches the very corruption it exists to reject.
# The result is `image: <repo>:` in the manifest, which is invalid
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
# stops reconciling, not just this one. That happened on 2026-08-25.
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
+52
View File
@@ -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
+90 -21
View File
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
collisions between parallel sessions: two were not branching problems at all — a
generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
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:
@@ -73,6 +89,26 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
## Default stack
| Layer | Default | Fallback | Last resort |
@@ -83,7 +119,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -92,7 +128,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
@@ -130,10 +171,10 @@ Three machines on Tailscale:
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh
## Project landscape
@@ -229,10 +270,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
re-add adapters for them:
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -244,15 +286,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
2. On any other host, `git pull` brings both. Which file a harness reads does
NOT split cleanly by lookup strategy, so do not reason from the strategy:
- `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
@@ -263,7 +319,20 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:**
+2 -4
View File
@@ -27,12 +27,12 @@ tasks:
echo "✓ context sync: skipped in CI"
else
bash scripts/context-sync.sh
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
if [ -n "$drift" ]; then
echo "ERROR: derived adapters drifted from canonical context." >&2
echo "$drift" >&2
echo "" >&2
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
echo " git commit -m 'chore: re-sync context adapters'" >&2
exit 1
fi
@@ -58,5 +58,3 @@ tasks:
cmds: [bash scripts/context-sync.sh claude]
context:sync:agents:
cmds: [bash scripts/context-sync.sh agents]
context:sync:cursor:
cmds: [bash scripts/context-sync.sh cursor]
+77
View File
@@ -0,0 +1,77 @@
// Command gitea-admin-mcp is a LOCAL-ONLY stdio MCP server exposing exactly
// one tool: mint_service_account_token.
//
// This exists because dispatch#67 fix #2 hit a hard wall: Gitea's own
// token-creation and admin-user endpoints reject token auth outright — an
// existing API token cannot create another token, or a new account, by
// design (a leaked token being usable to escalate would defeat the point of
// scoping it). The only way through is real basic auth as an actual admin,
// which a sandboxed agent session cannot safely hold. This binary is meant
// to be spawned by a LOCAL Claude Code session (or run by hand) on a machine
// with real, interactive 1Password access — configured as a stdio MCP
// server in .mcp.json, or invoked directly.
//
// Deliberately NOT part of cmd/gitea-mcp (the shared HTTP server): the
// capability here (reset a target account's password, mint it a token) is
// far too dangerous to ever expose over the network, even behind OIDC.
// internal/localtools is a separate package specifically so it can never be
// wired into that server's RegisterAll by accident.
//
// Required env (fetch admin credentials just-in-time, e.g. via
// `op run --env-file ~/.op-env -- gitea-admin-mcp`):
//
// GITEA_BASE_URL e.g. https://git.d-ma.be
// GITEA_ADMIN_USERNAME a real site-admin account's username
// GITEA_ADMIN_PASSWORD that account's real password
// OP_VAULT 1Password vault to store minted tokens in
// (default: AgentSecrets, per the estate's agent-governed
// ops-secrets policy)
package main
import (
"context"
"fmt"
"log/slog"
"os"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
// Logs to stderr, never stdout — stdout is the MCP JSON-RPC channel, and
// a stray log line there would corrupt the protocol stream.
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
baseURL := os.Getenv("GITEA_BASE_URL")
adminUser := os.Getenv("GITEA_ADMIN_USERNAME")
adminPass := os.Getenv("GITEA_ADMIN_PASSWORD")
if baseURL == "" || adminUser == "" || adminPass == "" {
fmt.Fprintln(os.Stderr, "GITEA_BASE_URL, GITEA_ADMIN_USERNAME, and GITEA_ADMIN_PASSWORD are all required")
os.Exit(1)
}
b := adminbootstrap.Bootstrapper{
BaseURL: baseURL,
AdminUsername: adminUser,
AdminPassword: adminPass,
Secrets: localtools.OpSecretStore{Vault: envOr("OP_VAULT", "AgentSecrets")},
}
reg := registry.New()
reg.Register(localtools.NewMintServiceAccountToken(b))
if err := mcp.ServeStdio(context.Background(), reg, os.Stdin, os.Stdout); err != nil {
logger.Error("serve stdio", "err", err)
os.Exit(1)
}
}
+61 -8
View File
@@ -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)
}
}
+106
View File
@@ -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
}
+6 -1
View File
@@ -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))
+20 -18
View File
@@ -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)
@@ -92,11 +88,17 @@ func main() {
mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
auth.PassthroughMiddleware(giteaClient, mcpSrv,
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
),
),
))
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))
+92
View File
@@ -0,0 +1,92 @@
# gitea-admin-mcp
A separate, local-only stdio MCP server. One tool: `mint_service_account_token`.
## Why this exists
dispatch#67 fix #2 needed a read-only CI credential for a dedicated service
account, and hit a hard wall: Gitea's own `POST /users/{u}/tokens` and
`POST /admin/users` both reject token auth outright —
{"message":"auth required"}
{"message":"token does not have at least one of required scope(s), required=[write:admin], ..."}
— by design: an existing API token cannot mint another token, or create an
account, no matter its scope. The only way through either endpoint is real
basic auth as an actual site admin. A sandboxed agent session has no
interactive password and shouldn't be handed one — that's broader exposure
than the job needs. This binary is meant to run where real admin credentials
already exist just-in-time: your own machine, via `op run`.
## What it does
`mint_service_account_token` does the whole bootstrap in one call:
1. Creates the named account if it doesn't exist (admin basic auth), or
resets its password if it does. **This is why `confirm_service_account`
is required and checked first** — the tool unconditionally (re)sets
whatever password the target account has, which is correct for a
dedicated bot account and would be destructive pointed at a real person's
login by mistake.
2. Mints a scoped token for that account, authenticating as the account
itself (Gitea requires this specifically — an admin cannot mint a token
on another account's behalf).
3. Grants the account read-only collaborator access on any named
`owner/repo` targets (admin action, least privilege).
4. Stores the token in 1Password via `op item create`, value passed on
stdin — never as a literal argv element, matching the estate's own
secret-handling rule. Returns only the `op://` reference, never the
token value itself, to the caller.
## Setup
Deliberately **not** wired into `cmd/gitea-mcp` (the shared HTTP server) —
this capability is too dangerous to expose over the network, even behind
OIDC. `internal/localtools` is a separate package specifically so it can
never be wired into that server's `RegisterAll` by accident.
Build:
```
go build -o gitea-admin-mcp ./cmd/gitea-admin-mcp
```
Configure as a local stdio MCP server (Claude Code `.mcp.json`, run where
your real `op` session lives):
```json
{
"mcpServers": {
"gitea-admin": {
"command": "op",
"args": ["run", "--env-file", "~/.op-env", "--", "/path/to/gitea-admin-mcp"]
}
}
}
```
Required env (see `~/.op-env`):
| Var | Meaning |
|---|---|
| `GITEA_BASE_URL` | e.g. `https://git.d-ma.be` |
| `GITEA_ADMIN_USERNAME` | a real site-admin account |
| `GITEA_ADMIN_PASSWORD` | that account's real password |
| `OP_VAULT` | 1Password vault for minted tokens (default `AgentSecrets`, per the estate's agent-governed ops-secrets policy) |
## Example call
```json
{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": ["read:repository", "read:issue"],
"token_name": "dispatch-ci-readonly",
"secret_name": "gitea-dispatch-bot-ci-readonly",
"grant_read_repos": ["mathias/cobalt-dingo"],
"confirm_service_account": true
}
```
Result carries `SecretRef` (e.g. `op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential`)
and `GrantedRepos` — never the token or either password.
+1 -1
View File
@@ -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
)
+6
View File
@@ -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=
+284
View File
@@ -0,0 +1,284 @@
// Package adminbootstrap mints a scoped Gitea access token for a dedicated
// service/bot account, end to end, in one auditable call.
//
// dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
// Gitea's own token-creation and admin-user endpoints reject token auth
// outright (POST /users/{u}/tokens and POST /admin/users both return
// "auth required" / a required-scope error for an existing token, by
// design — a leaked token being usable to mint further tokens would be a
// privilege-escalation footgun). Neither endpoint has a token-scoped
// workaround. The only way through is real basic auth as an actual admin.
//
// That is exactly what a sandboxed agent session cannot safely have — no
// interactive password, and handing one to a session would be broader
// exposure than the job needs. This package is meant to run LOCALLY, where
// real admin credentials exist just-in-time (1Password, `op run`), as a
// small stdio MCP tool (see cmd/gitea-admin-mcp) rather than ad hoc curl.
package adminbootstrap
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// HTTPDoer is the one method this package needs from *http.Client — narrow
// on purpose, so tests inject a fake transport instead of hitting a network.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// SecretStore persists the minted token somewhere durable and returns a
// reference safe to log or display — never the value itself. The real
// implementation (cmd/gitea-admin-mcp) shells out to the local `op` CLI;
// tests use an in-memory fake.
type SecretStore interface {
Store(ctx context.Context, name, value string) (ref string, err error)
}
// RepoRef names a repo to grant the new account read-only collaborator
// access to — least privilege: a service account should see only the repos
// it actually operates against, not every private repo on the instance.
type RepoRef struct {
Owner string
Name string
}
// Request describes one bootstrap. ConfirmServiceAccount is required and
// checked first: this package (re)sets whatever password Username already
// has, which is correct and safe for a dedicated bot account and dangerous
// for anything else.
type Request struct {
Username string
Email string
FullName string
Scopes []string
TokenName string
SecretName string
GrantReadRepos []RepoRef
ConfirmServiceAccount bool
}
// Result reports what happened. SecretRef, never the token value, is what a
// caller (or an MCP tool's text response) should ever surface.
type Result struct {
Username string
Created bool // false if the account already existed (password was reset, not created)
SecretRef string
GrantedRepos []string
}
// Bootstrapper holds the real admin credentials and where to reach Gitea —
// constructed once per invocation, never persisted.
type Bootstrapper struct {
BaseURL string
AdminUsername string
AdminPassword string
HTTP HTTPDoer // nil defaults to http.DefaultClient
Secrets SecretStore
}
func (b Bootstrapper) httpClient() HTTPDoer {
if b.HTTP != nil {
return b.HTTP
}
return http.DefaultClient
}
// Run creates (or resets the password of) Username, mints it a scoped
// token, grants it read-only access to GrantReadRepos, stores the token via
// Secrets, and returns without ever exposing the token value or either
// password to the caller.
func (b Bootstrapper) Run(ctx context.Context, req Request) (Result, error) {
if !req.ConfirmServiceAccount {
return Result{}, fmt.Errorf("refusing: ConfirmServiceAccount must be true — this resets %q's password unconditionally, which is only safe for a dedicated service/bot account", req.Username)
}
created, err := b.userExists(ctx, req.Username)
if err != nil {
return Result{}, fmt.Errorf("check existing account: %w", err)
}
created = !created // userExists returns true if found; Result.Created means "we created it"
botPassword, err := randomPassword()
if err != nil {
return Result{}, fmt.Errorf("generate account password: %w", err)
}
if created {
if err := b.createUser(ctx, req, botPassword); err != nil {
return Result{}, fmt.Errorf("create account: %w", err)
}
} else {
if err := b.resetPassword(ctx, req.Username, botPassword); err != nil {
return Result{}, fmt.Errorf("reset existing account's password: %w", err)
}
}
token, err := b.mintToken(ctx, req.Username, botPassword, req.TokenName, req.Scopes)
if err != nil {
return Result{}, fmt.Errorf("mint token: %w", err)
}
var granted []string
for _, r := range req.GrantReadRepos {
if err := b.grantReadAccess(ctx, r, req.Username); err != nil {
return Result{}, fmt.Errorf("grant read access on %s/%s: %w", r.Owner, r.Name, err)
}
granted = append(granted, r.Owner+"/"+r.Name)
}
ref, err := b.Secrets.Store(ctx, req.SecretName, token)
if err != nil {
return Result{}, fmt.Errorf("store token: %w", err)
}
return Result{
Username: req.Username,
Created: created,
SecretRef: ref,
GrantedRepos: granted,
}, nil
}
func randomPassword() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (b Bootstrapper) userExists(ctx context.Context, username string) (bool, error) {
resp, err := b.doAdmin(ctx, http.MethodGet, "/api/v1/users/"+username, nil)
if err != nil {
return false, err
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected status %d checking account existence", resp.StatusCode)
}
}
func (b Bootstrapper) createUser(ctx context.Context, req Request, password string) error {
body := map[string]any{
"username": req.Username,
"email": req.Email,
"password": password,
"must_change_password": false,
}
if req.FullName != "" {
body["full_name"] = req.FullName
}
resp, err := b.doAdmin(ctx, http.MethodPost, "/api/v1/admin/users", body)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) resetPassword(ctx context.Context, username, password string) error {
resp, err := b.doAdmin(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, map[string]any{
"password": password,
})
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
// mintToken authenticates as the SERVICE ACCOUNT itself (not the admin) —
// Gitea's token-creation endpoint requires basic auth as the token's owner,
// and an admin's own credentials cannot mint on another account's behalf.
func (b Bootstrapper) mintToken(ctx context.Context, username, password, tokenName string, scopes []string) (string, error) {
raw, err := json.Marshal(map[string]any{"name": tokenName, "scopes": scopes})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(b.BaseURL, "/")+"/api/v1/users/"+username+"/tokens", bytes.NewReader(raw))
if err != nil {
return "", err
}
req.SetBasicAuth(username, password)
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient().Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return "", statusErr(resp)
}
var parsed struct {
SHA1 string `json:"sha1"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if parsed.SHA1 == "" {
return "", fmt.Errorf("token response carried no sha1 value")
}
return parsed.SHA1, nil
}
// grantReadAccess is an admin action (adding a collaborator to a repo the
// admin owns), so it uses admin auth, not the new account's own.
func (b Bootstrapper) grantReadAccess(ctx context.Context, r RepoRef, username string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", r.Owner, r.Name, username)
resp, err := b.doAdmin(ctx, http.MethodPut, path, map[string]any{"permission": "read"})
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) doAdmin(ctx context.Context, method, path string, body map[string]any) (*http.Response, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(b.BaseURL, "/")+path, reader)
if err != nil {
return nil, err
}
req.SetBasicAuth(b.AdminUsername, b.AdminPassword)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return b.httpClient().Do(req)
}
func statusErr(resp *http.Response) error {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gitea returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
@@ -0,0 +1,258 @@
package adminbootstrap_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
// reject token auth outright, confirmed live against git.d-ma.be — an
// existing token cannot mint another token, by design). A sandboxed agent
// session has no interactive password, so this package is meant to run
// locally, where a real admin password is available just-in-time (1Password,
// `op run`), and to do the whole bootstrap in one auditable call rather than
// leaving it as ad hoc curl.
//
// Design choice, made explicit rather than silently assumed: this tool
// ALWAYS (re)sets the target account's password via admin basic auth before
// minting, whether the account is new or already existed. That is safe and
// expected for a dedicated service/bot account (its password is a mechanism
// to reach the token endpoint, never a human login credential) and would be
// dangerous pointed at a real person's account by mistake -- which is why
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
type call struct {
method, path string
body map[string]any
authUser string // basic-auth username used for this call, "" if token/none
authPass string
}
type fakeTransport struct {
t *testing.T
responses map[string]fakeResponse // key: "METHOD path"
calls []call
}
type fakeResponse struct {
status int
body string
}
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body map[string]any
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
if len(raw) > 0 {
require.NoError(f.t, json.Unmarshal(raw, &body))
}
}
user, pass, _ := req.BasicAuth()
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
key := req.Method + " " + req.URL.Path
r, ok := f.responses[key]
if !ok {
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
}
return &http.Response{
StatusCode: r.status,
Body: io.NopCloser(strings.NewReader(r.body)),
Header: make(http.Header),
}, nil
}
func keys(m map[string]fakeResponse) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// fakeSecretStore records what would have been written to 1Password, without
// ever touching it -- and, critically, asserts the token value itself never
// leaks into anything but this one call.
type fakeSecretStore struct {
stored map[string]string
}
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
if f.stored == nil {
f.stored = map[string]string{}
}
f.stored[name] = value
return "op://AgentSecrets/" + name + "/token", nil
}
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
ft := &fakeTransport{t: t, responses: responses}
fs := &fakeSecretStore{}
return adminbootstrap.Bootstrapper{
BaseURL: "http://fake.gitea",
AdminUsername: "mathias",
AdminPassword: "admin-pass-not-a-real-secret",
HTTP: &http.Client{Transport: ft},
Secrets: fs,
}, ft, fs
}
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository", "read:issue"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.True(t, result.Created)
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
// The account-creation call must use the ADMIN's basic auth, never the
// fresh account's own (unknown at that point) password.
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
assert.Equal(t, "mathias", create.authUser)
assert.Equal(t, "dispatch-bot", create.body["username"])
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
assert.Equal(t, false, create.body["must_change_password"])
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
// requires this specifically (an admin token cannot mint on someone
// else's behalf) -- with the password this package generated and set,
// never the admin's own password.
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
assert.Equal(t, "dispatch-bot", mint.authUser)
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
assert.NotEmpty(t, mint.authPass)
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
}
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
// Must PATCH (reset password), never POST (create) for an existing account.
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
assert.NotEmpty(t, patch.body["password"])
}
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
// Guard against pointing this at a real person's account by accident --
// this tool resets whatever password the target account has.
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "mathias",
Email: "mthbqv@gmail.com",
Scopes: []string{"read:repository"},
TokenName: "whatever",
SecretName: "whatever",
// ConfirmServiceAccount left false/unset
})
require.Error(t, err)
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
}
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
assert.Equal(t, "read", grant.body["permission"])
}
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "x",
SecretName: "x",
ConfirmServiceAccount: true,
})
require.Error(t, err)
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
}
func findCall(t *testing.T, calls []call, method, path string) call {
t.Helper()
for _, c := range calls {
if c.method == method && c.path == path {
return c
}
}
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
return call{}
}
func filterCalls(calls []call, method, path string) []call {
var out []call
for _, c := range calls {
if c.method == method && c.path == path {
out = append(out, c)
}
}
return out
}
+14 -2
View File
@@ -1,6 +1,11 @@
package allowlist
import "fmt"
import (
"context"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
type Allowlist struct {
owners map[string]struct{}
@@ -14,10 +19,17 @@ func New(owners []string) *Allowlist {
return &Allowlist{owners: m}
}
func (a *Allowlist) Check(owner string) error {
// Check gates owner access to the static list — except for a caller
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
// access Gitea's own permission model already gates more precisely than a
// coarse owner name list ever could.
func (a *Allowlist) Check(ctx context.Context, owner string) error {
if owner == "" {
return fmt.Errorf("owner required")
}
if _, ok := gitea.TokenFromContext(ctx); ok {
return nil
}
if _, ok := a.owners[owner]; !ok {
return fmt.Errorf("owner %q not in allowlist", owner)
}
+24 -4
View File
@@ -1,16 +1,36 @@
package allowlist_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
func TestAllowlistCheck(t *testing.T) {
a := allowlist.New([]string{"mathias", "acme"})
assert.NoError(t, a.Check("mathias"))
assert.NoError(t, a.Check("acme"))
assert.Error(t, a.Check("evil"))
assert.Error(t, a.Check(""))
ctx := context.Background()
assert.NoError(t, a.Check(ctx, "mathias"))
assert.NoError(t, a.Check(ctx, "acme"))
assert.Error(t, a.Check(ctx, "evil"))
assert.Error(t, a.Check(ctx, ""))
}
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
// is gated by Gitea's own permission model, not the MCP's static owner list —
// otherwise a legitimate second user could never touch their own repos.
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.NoError(t, a.Check(ctx, "someone-else"))
}
// Empty owner is a structural input error, not an authz question — still
// rejected even on the pass-through path.
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.Error(t, a.Check(ctx, ""))
}
+5 -2
View File
@@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
"x_forwarded_user", fwdUser)
}
ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
})
}
func withCaller(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, ctxKey{}, user)
}
func Caller(ctx context.Context) string {
if v, ok := ctx.Value(ctxKey{}).(string); ok {
return v
+62
View File
@@ -0,0 +1,62 @@
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"
)
// TokenValidator asks the upstream service who a bearer token belongs to.
type TokenValidator interface {
ValidateToken(ctx context.Context, token string) (username string, ok bool)
}
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
// if the request's bearer token validates directly against Gitea, it's used
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
// the server's shared default token. Any other bearer (static token, JWT, or
// none) falls through to fallback unchanged — this only adds a capability, it
// never removes the existing auth paths.
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
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)
})
}
+83
View File
@@ -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")
}
+82
View File
@@ -0,0 +1,82 @@
package auth_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
type fakeValidator struct {
login string
ok bool
calls int
}
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
f.calls++
return f.login, f.ok
}
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
validator := &fakeValidator{login: "alice", ok: true}
var gotCaller string
var gotToken string
var gotOK bool
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotCaller = auth.Caller(r.Context())
gotToken, gotOK = gitea.TokenFromContext(r.Context())
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("fallback should not be called for a valid PAT")
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer alices-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, "alice", gotCaller)
assert.True(t, gotOK)
assert.Equal(t, "alices-pat", gotToken)
}
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
validator := &fakeValidator{ok: false}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called for an invalid token")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
}
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
validator := &fakeValidator{ok: true, login: "alice"}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called with no Authorization header")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
}
+39
View File
@@ -3,6 +3,7 @@ package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -19,6 +20,21 @@ type Client struct {
branchCache *expirable.LRU[string, string]
}
type ctxTokenKey struct{}
// WithToken overrides the token used for upstream Gitea calls made with the
// returned context, taking precedence over the Client's configured default
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
func WithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, ctxTokenKey{}, token)
}
// TokenFromContext returns the token set by WithToken, if any.
func TokenFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxTokenKey{}).(string)
return v, ok
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
@@ -28,6 +44,23 @@ func NewClient(baseURL, token string) *Client {
}
}
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
// using that token, not the client's configured default token) and returns
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
if err != nil || status != http.StatusOK {
return "", false
}
var user struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
return "", false
}
return user.Login, true
}
// DefaultBranch returns the default branch for a repo. Cached for 60s.
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
key := owner + "/" + name
@@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
return nil, 0, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
@@ -135,6 +171,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
return nil, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
+2 -2
View File
@@ -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 {
+43
View File
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
}
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
ctx := gitea.WithToken(context.Background(), "caller-token")
_, status, err := c.GetJSON(ctx, "/api/v1/user")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Equal(t, "token caller-token", gotAuth)
}
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "candidate-token")
assert.True(t, ok)
assert.Equal(t, "alice", login)
}
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "bad-token")
assert.False(t, ok)
assert.Empty(t, login)
}
func TestRetryOnPostNotRetried(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+135 -15
View File
@@ -1,10 +1,13 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"encoding/base64"
"fmt"
"net/url"
"path"
"sort"
"strings"
)
type CodeSearchHit struct {
@@ -14,30 +17,147 @@ type CodeSearchHit struct {
Score float64 `json:"score,omitempty"`
}
type codeSearchEnvelope struct {
Data []CodeSearchHit `json:"data"`
OK bool `json:"ok"`
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
// and scan, so one call against a huge repo can't run indefinitely.
const codeSearchMaxFiles = 2000
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
// before any fetch) — almost certainly binary/vendor/generated content, not
// worth the cost of fetching just to reject.
const codeSearchMaxFileSize = 512 * 1024
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
// obviously-binary content by extension, checked against the tree listing.
var codeSearchBinaryExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
".pyc": true, ".o": true, ".a": true,
}
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
// has no code-content-search endpoint — confirmed against a live 1.25.5
// instance's swagger spec (only /repos/search, /repos/issues/search,
// /topics/search etc exist). The web UI's OWN code search falls back to
// server-side `git grep` because no Repository Indexer is enabled on that
// instance, and that fallback is an HTML-only route, not JSON API. So this
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
// user-supplied input) — against file contents.
//
// Pagination is over the FULL sorted result set, recomputed on every call —
// there is no server-side index to page through incrementally, so requesting
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
// fix, and is an infra decision, not something gitea-mcp controls.
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
if q == "" {
return nil, fmt.Errorf("q is required: %w", ErrValidation)
}
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
owner, repo, url.QueryEscape(q), page, limit)
body, status, err := c.GetJSON(ctx, path)
r, err := c.GetRepo(ctx, owner, repo)
if err != nil {
return nil, err
return nil, fmt.Errorf("resolve repo: %w", err)
}
if err := MapStatus(status, body); err != nil {
return nil, err
branch := r.DefaultBranch
if branch == "" {
branch = "main"
}
var env codeSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
tree, err := c.GetTree(ctx, owner, repo, branch, true)
if err != nil {
return nil, fmt.Errorf("tree walk: %w", err)
}
return env.Data, nil
qLower := strings.ToLower(q)
all := make([]CodeSearchHit, 0)
scanned := 0
for _, e := range tree.Tree {
if ctx.Err() != nil {
break
}
if e.Type != "blob" {
continue
}
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
continue
}
if e.Size > codeSearchMaxFileSize {
continue
}
if scanned >= codeSearchMaxFiles {
break
}
scanned++
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
if ferr != nil {
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
}
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
if derr != nil {
continue
}
if bytes.IndexByte(decoded, 0) >= 0 {
continue // binary content the extension filter missed
}
content := string(decoded)
contentLower := strings.ToLower(content)
count := strings.Count(contentLower, qLower)
if count == 0 {
continue
}
idx := strings.Index(contentLower, qLower)
all = append(all, CodeSearchHit{
Path: e.Path,
Snippet: codeSearchSnippet(content, idx, len(q)),
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
Score: float64(count),
})
}
sort.Slice(all, func(i, j int) bool {
if all[i].Score != all[j].Score {
return all[i].Score > all[j].Score
}
return all[i].Path < all[j].Path
})
start := (page - 1) * limit
if start >= len(all) {
return []CodeSearchHit{}, nil
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
// codeSearchSnippet returns a short window of text centered on a match,
// trimmed to a single line-ish window so results read like a grep hit rather
// than a content dump.
func codeSearchSnippet(content string, idx, matchLen int) string {
const window = 60
start := idx - window
if start < 0 {
start = 0
}
end := idx + matchLen + window
if end > len(content) {
end = len(content)
}
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
return strings.TrimSpace(snippet)
}
+150 -17
View File
@@ -2,8 +2,12 @@ package gitea_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -11,22 +15,65 @@ import (
"github.com/stretchr/testify/require"
)
func TestSearchCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
// UI's own code search falls back to server-side `git grep`, an HTML-only
// route, not JSON API). Records which paths were actually fetched, so tests
// can assert a file was SKIPPED (binary ext, oversized) without ever being
// read, not just absent from results.
type codeSearchFake struct {
files map[string]string // path -> content
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
fetched []string
}
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/code_search.go",
"snippet":"func (c *Client) SearchCode",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
"score":2.5
}],
"ok":true
}`))
}))
p := r.URL.Path
switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
var entries []string
for path, content := range f.files {
size := int64(len(content))
if s, ok := f.sizes[path]; ok {
size = s
}
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
f.fetched = append(f.fetched, path)
content, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
}
func TestSearchCode_FindsMatchInTree(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
@@ -34,6 +81,92 @@ func TestSearchCode(t *testing.T) {
require.NoError(t, err)
require.Len(t, hits, 1)
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
assert.Contains(t, hits[0].Snippet, "SearchCode")
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
assert.Equal(t, 1.0, hits[0].Score)
}
func TestSearchCode_CaseInsensitive(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"README.md": "# MyProject\n\nBuild instructions here.\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
require.NoError(t, err)
require.Len(t, hits, 1)
}
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"assets/logo.png": "SearchCode", // would match if fetched — must not be
"main.go": "package main\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
}
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
f := &codeSearchFake{
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
}
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"data.bin": "SearchCode\x00binary-marker",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
}
func TestSearchCode_Pagination(t *testing.T) {
files := map[string]string{}
for i := 0; i < 5; i++ {
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
}
f := &codeSearchFake{files: files}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
require.NoError(t, err)
require.Len(t, page1, 2)
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
require.NoError(t, err)
require.Len(t, page2, 2)
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
}
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
c := gitea.NewClient("http://unused", "tok")
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}
-1
View File
@@ -255,4 +255,3 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
}
return &r, nil
}
+2 -2
View File
@@ -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,100 @@
// Package localtools holds MCP tools that must never be reachable from the
// shared HTTP gitea-mcp server (RegisterAll, cmd/gitea-mcp). Each tool here
// carries a capability too dangerous to expose network-side, even behind
// OIDC — mint_service_account_token resets whatever password its target
// account has. Only cmd/gitea-admin-mcp, a separate local-only stdio binary,
// imports this package.
package localtools
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// bootstrapper is the one method this tool needs — narrow so tests inject a
// fake instead of a real adminbootstrap.Bootstrapper.
type bootstrapper interface {
Run(ctx context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error)
}
type MintServiceAccountToken struct {
b bootstrapper
}
func NewMintServiceAccountToken(b bootstrapper) *MintServiceAccountToken {
return &MintServiceAccountToken{b: b}
}
func (t *MintServiceAccountToken) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "mint_service_account_token",
Description: "Create (or reset the password of) a dedicated Gitea " +
"service/bot account, mint it a scoped access token, optionally " +
"grant it read-only collaborator access on named repos, and store " +
"the token via 1Password. Requires confirm_service_account=true: " +
"this resets whatever password the target account currently has, " +
"which is only safe for a dedicated bot account, never a real " +
"person's login.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"username":{"type":"string"},
"email":{"type":"string"},
"full_name":{"type":"string"},
"scopes":{"type":"array","items":{"type":"string"}},
"token_name":{"type":"string"},
"secret_name":{"type":"string","description":"name to store the minted token under"},
"grant_read_repos":{"type":"array","items":{"type":"string"},"description":"owner/repo strings"},
"confirm_service_account":{"type":"boolean"}
},
"required":["username","email","scopes","token_name","secret_name","confirm_service_account"]
}`),
}
}
type mintArgs struct {
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Scopes []string `json:"scopes"`
TokenName string `json:"token_name"`
SecretName string `json:"secret_name"`
GrantReadRepos []string `json:"grant_read_repos"`
ConfirmServiceAccount bool `json:"confirm_service_account"`
}
func (t *MintServiceAccountToken) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args mintArgs
if err := json.Unmarshal(raw, &args); err != nil {
return nil, fmt.Errorf("parse arguments: %w", err)
}
repos := make([]adminbootstrap.RepoRef, 0, len(args.GrantReadRepos))
for _, r := range args.GrantReadRepos {
owner, name, ok := strings.Cut(r, "/")
if !ok || owner == "" || name == "" {
return nil, fmt.Errorf("grant_read_repos entry %q must be owner/repo", r)
}
repos = append(repos, adminbootstrap.RepoRef{Owner: owner, Name: name})
}
result, err := t.b.Run(ctx, adminbootstrap.Request{
Username: args.Username,
Email: args.Email,
FullName: args.FullName,
Scopes: args.Scopes,
TokenName: args.TokenName,
SecretName: args.SecretName,
GrantReadRepos: repos,
ConfirmServiceAccount: args.ConfirmServiceAccount,
})
if err != nil {
return nil, err
}
return json.Marshal(result)
}
@@ -0,0 +1,110 @@
package localtools_test
import (
"context"
"encoding/json"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This tool lives in its own package, never internal/tools, on purpose: it
// resets whatever password its target account has, which is far too
// dangerous a capability to risk it ever being wired into RegisterAll and
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
// separate, local-only, stdio binary) imports this package.
type fakeBootstrapper struct {
gotReq adminbootstrap.Request
result adminbootstrap.Result
err error
}
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
f.gotReq = req
return f.result, f.err
}
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
fb := &fakeBootstrapper{result: adminbootstrap.Result{
Username: "dispatch-bot", Created: true,
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
}}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository", "read:issue"},
"token_name": "dispatch-ci-readonly",
"secret_name": "gitea-dispatch-bot-ci-readonly",
"grant_read_repos": []string{"mathias/cobalt-dingo"},
"confirm_service_account": true,
})
out, err := tool.Call(context.Background(), args)
require.NoError(t, err)
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
// The tool's own output must never carry a raw token or password field --
// adminbootstrap.Result structurally can't (it has none), which this pins.
_, hasToken := result["Token"]
_, hasPassword := result["Password"]
assert.False(t, hasToken)
assert.False(t, hasPassword)
}
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
fb := &fakeBootstrapper{}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository"},
"token_name": "x",
"secret_name": "x",
"grant_read_repos": []string{"not-owner-slash-repo"},
"confirm_service_account": true,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.Contains(t, err.Error(), "owner/repo")
}
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
fb := &fakeBootstrapper{err: assert.AnError}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository"},
"token_name": "x",
"secret_name": "x",
"confirm_service_account": true,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
}
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
d := tool.Descriptor()
assert.Equal(t, "mint_service_account_token", d.Name)
assert.NotEmpty(t, d.Description)
}
+50
View File
@@ -0,0 +1,50 @@
package localtools
import (
"context"
"fmt"
"os/exec"
"strings"
)
// OpSecretStore implements adminbootstrap.SecretStore against the local
// `op` (1Password) CLI. Run is injectable for tests; nil defaults to
// actually invoking `op`.
//
// The token value is passed via the child process's stdin, never as a
// literal argv element — matching the estate's own secret-handling rule
// (a value in argv lands in process listings and any transcript of the
// command). `credential[password]=-` is `op item create`'s documented way
// to read one field's value from stdin instead of the command line.
type OpSecretStore struct {
Vault string
Run func(ctx context.Context, stdin string, args ...string) error
}
func (s OpSecretStore) Store(ctx context.Context, name, value string) (string, error) {
run := s.Run
if run == nil {
run = runOp
}
args := []string{
"item", "create",
"--category=login",
"--vault=" + s.Vault,
"--title=" + name,
"credential[password]=-",
}
if err := run(ctx, value, args...); err != nil {
return "", fmt.Errorf("op item create: %w", err)
}
return fmt.Sprintf("op://%s/%s/credential", s.Vault, name), nil
}
func runOp(ctx context.Context, stdin string, args ...string) error {
cmd := exec.CommandContext(ctx, "op", args...)
cmd.Stdin = strings.NewReader(stdin)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package localtools_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The estate's own secret-handling rule: never place a literal secret in a
// command's argv — it lands in process listings and any transcript of the
// command. OpSecretStore feeds the token to `op` via stdin instead.
func TestOpSecretStore_PassesValueViaStdinNeverArgv(t *testing.T) {
var gotStdin string
var gotArgs []string
s := localtools.OpSecretStore{
Vault: "AgentSecrets",
Run: func(_ context.Context, stdin string, args ...string) error {
gotStdin = stdin
gotArgs = args
return nil
},
}
ref, err := s.Store(context.Background(), "gitea-dispatch-bot-ci-readonly", "tok_the_actual_secret_value")
require.NoError(t, err)
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential", ref)
assert.Equal(t, "tok_the_actual_secret_value", gotStdin)
for _, a := range gotArgs {
assert.NotContains(t, a, "tok_the_actual_secret_value",
"the secret value must never appear as a literal argv element")
}
assert.Contains(t, gotArgs, "--vault=AgentSecrets")
assert.Contains(t, gotArgs, "--title=gitea-dispatch-bot-ci-readonly")
}
func TestOpSecretStore_PropagatesRunError(t *testing.T) {
s := localtools.OpSecretStore{
Vault: "AgentSecrets",
Run: func(_ context.Context, _ string, _ ...string) error {
return assert.AnError
},
}
_, err := s.Store(context.Background(), "x", "y")
require.Error(t, err)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
const (
ProtocolVersion = "2025-03-26"
ProtocolVersion = "2025-03-26"
maxRequestBodyBytes = 1 << 20 // 1 MiB
)
+95
View File
@@ -0,0 +1,95 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// ServeStdio runs the MCP JSON-RPC protocol over stdio: one JSON-RPC message
// per line on in, one response per line on out — the standard MCP stdio
// framing, and how Claude Code spawns a LOCAL MCP server as a subprocess.
//
// Built for gitea-admin-mcp (see cmd/gitea-admin-mcp), which has to run
// locally where real admin credentials exist just-in-time, rather than
// through the hosted HTTP server (server.go) a sandboxed agent session talks
// to. The method dispatch below intentionally mirrors handlePOST in
// server.go — same three methods, same error codes — so a tool written
// against registry.Tool behaves identically over either transport; it is
// kept as a separate small loop rather than a shared refactor of the HTTP
// handler, which mixes in http.ResponseWriter specifics this transport
// doesn't have.
func ServeStdio(ctx context.Context, reg *registry.Registry, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // 1 MiB max line, matches server.go's request cap
enc := json.NewEncoder(out)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if werr := enc.Encode(NewErrorResponse(nil, -32700, "parse error", nil)); werr != nil {
return werr
}
continue
}
if req.ID == nil {
continue // notification — no response, per JSON-RPC 2.0
}
resp := dispatch(ctx, reg, req)
if err := enc.Encode(resp); err != nil {
return err
}
}
return scanner.Err()
}
// dispatch handles one already-parsed request. Mirrors server.go's
// handlePOST switch exactly (initialize / tools/list / tools/call / unknown).
func dispatch(ctx context.Context, reg *registry.Registry, req Request) Response {
if req.Method == "initialize" {
return NewResponse(req.ID, map[string]any{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "gitea-admin-mcp", "version": "0.1.0"},
})
}
switch req.Method {
case "tools/list":
return NewResponse(req.ID, map[string]any{"tools": reg.Tools()})
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return NewErrorResponse(req.ID, -32602, "invalid params", nil)
}
out, err := reg.Dispatch(ctx, p.Name, p.Arguments)
if err != nil {
code := -32000
if errors.Is(err, registry.ErrToolNotFound) {
code = CodeNotFound
}
return NewErrorResponse(req.ID, code, err.Error(), nil)
}
return NewResponse(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(out)}},
})
default:
return NewErrorResponse(req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method), nil)
}
}
+116
View File
@@ -0,0 +1,116 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"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"
)
// The existing MCP server (server.go) only speaks HTTP. gitea-admin-mcp needs
// to run as a LOCAL stdio server (Claude Code's local .mcp.json stdio server
// type, spawned as a subprocess with real 1Password/admin auth already in its
// environment) -- reusing the exact same registry.Tool interface and
// jsonrpc.go message types, one JSON-RPC message per line on stdin/stdout,
// which is the standard MCP stdio framing.
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
}
func TestServeStdio_Initialize(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
assert.EqualValues(t, 1, resp.ID)
assert.Nil(t, resp.Error)
}
func TestServeStdio_ToolsListAndCall(t *testing.T) {
reg := registry.New()
reg.Register(echoTool{})
in := strings.NewReader(
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"hello":"world"}}}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 2, "one JSON-RPC response per line, in request order")
var listResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[0]), &listResp))
assert.EqualValues(t, 1, listResp.ID)
var callResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[1]), &callResp))
assert.EqualValues(t, 2, callResp.ID)
require.NotNil(t, callResp.Result)
}
func TestServeStdio_NotificationGetsNoResponse(t *testing.T) {
// A request with no "id" is a notification per JSON-RPC 2.0 -- must not
// write anything back, and must not stop the loop from processing what
// follows it.
reg := registry.New()
in := strings.NewReader(
`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 1, "the notification must not produce a response line")
}
func TestServeStdio_UnknownMethodReturnsJSONRPCError(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"not/a/real/method"}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32601, resp.Error.Code)
}
func TestServeStdio_UnparseableLineReturnsParseError(t *testing.T) {
reg := registry.New()
in := strings.NewReader("not json at all\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err, "a malformed line must not crash the loop")
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32700, resp.Error.Code)
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
+108 -75
View File
@@ -2,8 +2,10 @@ package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -16,22 +18,76 @@ import (
"github.com/stretchr/testify/require"
)
func TestCodeSearchSingleRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
// for a set of repos — the real endpoints code_search's underlying SearchCode
// now uses (Gitea's REST API has no code-content-search endpoint; see
// internal/gitea/code_search.go's doc comment).
type multiRepoSearchFake struct {
owner string
repos []string // ListRepos response, in this order
files map[string]map[string]string // repo -> path -> content
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
}
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/repos.go",
"snippet":"func (c *Client) ListRepos",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
"score":3.0
}],
"ok":true
}`))
}))
p := r.URL.Path
for _, repo := range f.repos {
base := "/api/v1/repos/" + f.owner + "/" + repo
switch {
case p == base && f.fail[repo]:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
return
case p == base:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
return
case strings.HasPrefix(p, base+"/git/trees/"):
var entries []string
for path := range f.files[repo] {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
return
case strings.HasPrefix(p, base+"/contents/"):
path := strings.TrimPrefix(p, base+"/contents/")
content, ok := f.files[repo][path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
enc := base64.StdEncoding.EncodeToString([]byte(content))
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
return
}
}
if p == "/api/v1/users/"+f.owner+"/repos" {
var entries []string
for _, repo := range f.repos {
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
}
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
return
}
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
func TestCodeSearchSingleRepo(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra"},
files: map[string]map[string]string{
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
}
func TestCodeSearchAllowlistRejects(t *testing.T) {
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
}
func TestCodeSearchFanOutHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
case "/api/v1/repos/mathias/gitea-mcp/search":
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "gitea-mcp"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
}
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/broken/search":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "broken"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
},
fail: map[string]bool{"broken": true},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -134,9 +175,11 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
require.NoError(t, err)
var result struct {
Results []struct{ Repo string `json:"repo"` } `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
Results []struct {
Repo string `json:"repo"`
} `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.True(t, result.Partial)
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
}
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/alpha/search":
// low score
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/beta/search":
// high score
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"alpha", "beta"},
files: map[string]map[string]string{
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 2)
// First result must be the high-score one
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
assert.True(t, result.Results[0].Score > result.Results[1].Score,
"expected results sorted by score desc, got %v then %v",
result.Results[0].Score, result.Results[1].Score)
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
}
@@ -111,7 +111,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
}
// Allowlist check first.
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Sha == "" {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+6 -4
View File
@@ -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()
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.State == "" {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -63,7 +63,7 @@ func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -44,7 +44,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
state := args.State
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -55,7 +55,7 @@ func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.Raw
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
createArgs := gitea.CreateRepoArgs{
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Confirm != args.Repo {
+1 -1
View File
@@ -38,7 +38,7 @@ func (t *RepoGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
r, err := t.c.GetRepo(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 30)
+1 -1
View File
@@ -96,7 +96,7 @@ func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.Ra
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
switch args.Action {
+2 -2
View File
@@ -53,7 +53,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if args.Owner != "" {
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
}
@@ -75,7 +75,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if len(parts) != 2 {
continue
}
if t.a.Check(parts[0]) == nil {
if t.a.Check(ctx, parts[0]) == nil {
filtered = append(filtered, r)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if err := t.c.UpdateTopics(ctx, args.Owner, args.Repo, args.Topics); err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
tree, err := t.c.GetTree(ctx, args.Owner, args.Repo, args.Ref, true)
+1 -1
View File
@@ -60,7 +60,7 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Tag == "" {
+2 -2
View File
@@ -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",
@@ -145,7 +145,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Path == "" || args.Content == "" || args.Message == "" {
+1 -1
View File
@@ -36,7 +36,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()
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 10)
+5 -5
View File
@@ -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",
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *WorkflowRunStatus) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.RunID < 1 {
+1 -1
View File
@@ -62,7 +62,7 @@ func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (jso
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Workflow == "" {
+5 -28
View File
@@ -5,6 +5,11 @@
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
# For everything else: concatenates root AGENT.md + project PROJECT.md
#
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
# Mods and raw API use.
#
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
# Task: task context:sync
#
@@ -95,30 +100,6 @@ generate_agents() {
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
}
# ── Cursor ───────────────────────────────────────────────────
generate_cursor() {
{
echo "# Cursor rules — auto-generated"
echo "# Do not edit. Run: task context:sync"
echo ""
root_block
cat "$PROJECT_FILE"
} > .cursorrules
echo " → .cursorrules (root + project)"
}
# ── Aider ────────────────────────────────────────────────────
generate_aider() {
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
if [ ! -f .aider.conf.yml ]; then
cat > .aider.conf.yml << 'YAML'
read: .aider.conventions.md
auto-commits: false
YAML
fi
echo " → .aider.conventions.md (root + project)"
}
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
generate_system_prompt() {
{
@@ -180,8 +161,6 @@ echo "Syncing project context from $PROJECT_FILE..."
if [ ${#ADAPTERS[@]} -eq 0 ]; then
generate_claude
generate_agents
generate_cursor
generate_aider
generate_system_prompt
generate_mcp
else
@@ -189,8 +168,6 @@ else
case "$adapter" in
claude) generate_claude ;;
agents) generate_agents ;;
cursor) generate_cursor ;;
aider) generate_aider ;;
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
mcp) generate_mcp ;;
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;