ContextAdapterDrift was firing for 13 repos. Picks up behaviour rule 7
("Finish what's closable"), which landed in ~/dev/.context/AGENT.md and never
reached any repo's derived files.
Where mcp.json changes, it also picks up root MCP config the repo was stale on:
brain/gitea/infra move from public hostnames to tailnet Serve URLs, and gitea's
bearer changes from GITEA_MCP_TOKEN to DMABE_GITEA_API_TOKEN — gitea-mcp
authenticates by pass-through and 401s on the former, so a harness syncing from
the old file had a dead gitea server and no signal saying so.
No literal secrets: mcp.json carries ${VAR} placeholders only.
CLAUDE.md deliberately unchanged — context-sync writes it from PROJECT.md alone,
since Claude Code walks up to ~/dev/CLAUDE.md for root context.
Refs mathias/infra ContextAdapterDrift
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
`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>
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.
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.
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.
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.
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.
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
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
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
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
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
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>
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>
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>
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>
Extends the existing dispatch_allow flag (which already injects .dispatch-allow,
#43/#51/#53) to also append owner/name to mathias/dispatch's git-tracked
dispatch-repos.txt (dispatch#19) — the second of the two required dispatch
gates. Being listed there is necessary but not sufficient on its own (the repo
still needs its own .dispatch-allow marker) — both are applied independently,
neither implies the other, matching dispatch#3's structural trust-zone design
where both must say yes.
Idempotent: a repo already listed (e.g. dispatch_allow re-requested on resume)
is a no-op, not a duplicate line. Failure is reported in its own
dispatch_allowlist_failure field, distinct from partial_failure (substitution)
and dispatch_allow_failure (the marker file) — the three gates can each fail
independently, never conflated (same principle as #51).
The allowlist owner/repo/path/branch are hardcoded to match mathias/dispatch's
own DISPATCH_ALLOWLIST_* env defaults, confirmed unoverridden in the live
CronJob (2026-07-06) before implementing.
Tests: fresh registration (existing entries preserved, not clobbered),
already-listed no-op (zero writes), allowlist-write failure as a distinct
field, dispatch_allow=false never touches the file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive: gitea-mcp now validates JWTs from a LIST of issuers — the existing
Authentik issuer (DEX_ISSUER_URL) AND, when K8S_ISSUER_URL is set, the in-cluster
k8s OIDC issuer for audience-bound ServiceAccount tokens. Lets in-cluster pods
authenticate with kubelet-rotated projected SA tokens instead of a static bearer.
- config: K8S_ISSUER_URL + K8S_MCP_AUDIENCE.
- cmd/gitea-mcp/k8soidc.go: HTTP client that fetches the k8s OIDC discovery/JWKS
with the cluster CA + this pod's SA bearer (k3s requires an authed fetch;
anonymous is 401).
- main.go: build the issuer list; switch NewJWTValidator -> NewMultiJWTValidator.
The k8s issuer is best-effort — if its client can't be built (not in a pod) or
its discovery is unreachable at startup, it is DROPPED and we fall back so
Authentik/static auth is never taken down. Smoke-tested: off-pod it logs the
skip and starts static-only; static-bearer /mcp returns 400 (auth passed), not 401.
- bump mcp-chassis v0.3.0 -> v0.5.0 (multi-issuer + per-issuer HTTPClient).
Refs infra ADR-0011; enables retiring the in-cluster static bearer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename path (write new path, then delete old path) is two separate,
non-atomic API calls. If a prior partial run's write succeeded but the paired
delete failed — plausible under the same infra#179 flakiness resume (#50)
exists to work around — a resume would recompute the identical rename and
blind-create (no sha) at a path that already exists, hitting a conflict and
getting stuck needing another resume cycle just to re-report the same thing.
renameEntry now reads the new path first: if it already holds the correct
content (prior write succeeded), the write is skipped and only the
outstanding delete of the old path runs; if the new path exists but differs,
it's updated with the fetched sha instead of blind-created; if the old path
is already gone by delete time, that's treated as done, not a failure.
Mirrors injectDispatchAllow's (#51) read-before-write idempotency pattern.
Test: TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly — a stray
old path plus an already-correct new path resolves to a single delete, zero
redundant writes, no partial_failure. All prior create_project tests
unaffected (backward compatible).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent adversarial review of #52 (v0.8.0) caught a schema/implementation
mismatch: the advertised InputSchema marked "labels" as required, but Call
already treated labels/label_ids as either-or. An MCP client that validates
arguments against the advertised schema before dispatch would reject a
label_ids-only call as invalid even though the code was written to serve it —
and that path had zero test coverage either way.
Dropped "labels" from the required array (owner/repo/number remain required);
runtime validation already correctly requires at least one of labels/label_ids.
Added TestIssueLabelAppliesByIDOnly (asserts ListLabels is never called when
IDs are already known) and a schema-lock test for the fixed contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parallax#1 was the real-world evidence: infra#179's branch-writability stall
(>40s observed) blew past the tool's 5s budget, substitution ran zero files,
and the repo shipped genuinely broken (go.mod still read `module
__MODULE_PATH__`) — recoverable only via a manual clone/sed/git-mv/push.
Adds `resume: bool`. When true, skips template lookup and repo generation
entirely — the destination must already exist — and jumps straight to the
tree-walk substitution. This is safe to re-invoke repeatedly because
substituteEntry already compares against the branch's CURRENT state on every
call (GetTree is re-fetched fresh each time): a file already fixed in a prior
partial pass shows up already-correct or already-renamed and is a no-op. So the
actual blocker to resumability was purely the "destination must NOT exist"
guard on the create path — flipped it for resume, no change needed to the
substitution logic itself.
Consequences of resume existing:
- infra179FinalizeMessage (#46) now points at the concrete recovery — "call
this tool again with resume=true" — instead of manual clone/sed guidance.
- "no placeholders substituted" only loud-fails on a FRESH create; on resume,
finding nothing left to do is the expected steady state (success).
- dispatch_allow injection (#43) is now idempotent (read-before-write, update
with sha if present-but-different, skip if already correct) so a resumed
call with dispatch_allow=true doesn't error re-creating a path that already
exists (#51's root cause).
- #51's other ask: dispatch_allow failures now land in their own
dispatch_allow_failure field, never conflated with substitution's
partial_failure — the two can independently succeed/fail.
Tests: resume with no destination (error, names "nothing to resume"), resume
continuing a partial substitution (asserts /generate is never re-called, only
the still-wrong file is rewritten), resume when already fully done (success,
not the fresh-create loud-fail), dispatch_allow idempotent re-injection
(two-phase test capturing the real written content, no test-visible knowledge
of the internal constant), and dispatch_allow_failure as a distinct field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#52 — unblocks parallax#3 dispatch labeling, which needs to both
discover a repo's label set and attach labels to an issue by name (the
CAD pipeline doesn't track Gitea's internal numeric label IDs).
- gitea.Client: ListLabels, AddIssueLabels (additive POST, matches
Gitea's own semantics — no delete-then-post needed); extend the
existing Label struct with Color for label_list's output.
- tools.LabelList (read-only, allowlisted): lists a repo's labels.
- tools.IssueLabel (allowlisted): resolves label names to IDs via
ListLabels, so callers pass names (the primary interface) instead of
hunting for numeric IDs; also accepts label_ids for callers that
already have them. An unknown name fails closed, naming exactly which
label wasn't found.
- Bump TestRegisteredToolCount 39 -> 41 in the same commit (this
project was bitten today by a locked count going stale silently).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mirror credential no longer has to ride the tool-call payload (which is
persisted to transcript → claudewatcher → brain → gitea history). Adds
remote_password_env: the name of a server-side env var the tool resolves at call
time, so the secret stays in the server process. An env name that resolves to
empty errors loudly rather than silently sending an empty password. Raw
remote_password still works but the schema/description now mark it DISCOURAGED.
Tests: password resolved from the env var (never in output); unset env var →
ErrValidation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second tbd_ship for the same change no longer errors on "branch exists":
CreateBranch conflict is tolerated, and if a PR is already open for the head,
CreatePullRequest's conflict/validation error resolves it via ListPullRequests
(matching head.ref). Identical file content on the branch skips the write, so a
resume produces no redundant empty-diff commit. Then the same CI gate runs and
merges if now green — so "poll or re-invoke" (the #40 UX) actually works.
Test: TestTBDShip_Resume_ExistingBranchAndPR (branch+PR exist, content
unchanged → no write, merges when green). First-call paths unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dockerfile takes ARG VERSION (default "dev") and stamps it into
main.version with -X. CD passes --build-arg VERSION=<git tag on v* builds,
else the short sha>, so the running pod logs the actual build instead of
"dev". Verified locally: `-ldflags -X main.version=...` embeds the string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The registered-tool-count lock still expected 38; tbd_ship makes 39. This is
why the v0.6.0 CD check job went red (build+deploy skipped, pod stayed on
v0.5.2). Count updated; task check green (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Call-level tests for the two remaining no-merge paths (green CI but the
base requires review, and green CI but the merge returns 409), completing the
acceptance matrix alongside the green/pending/red/no-CI cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An intent verb for the trunk-based loop: branch from base → write the file →
open a PR → auto-merge (squash) → delete the branch. One call instead of
orchestrating file_write_branch + pr_create + workflow_run_status + pr_merge +
branch_delete and remembering the conventions each time.
The load-bearing safety is a pure, fail-closed CI gate (evaluateShipGate):
merge=true ONLY when every workflow run for the PR head commit is
completed+success AND the base branch is not review-protected. Every other
state — CI pending / red / absent, review-required base, or an unclean merge —
fails closed to PR-only and returns the PR with a reason. A change with no CI
gate is never auto-merged to trunk (cf. agentsquad#36: non-compiling code
reviewer-approved straight to main with no CI wall).
- ci_timeout_seconds polls the head commit's runs to completion (default 0 =
snapshot, returns pending right after opening the PR).
- Derives a deterministic short-lived branch (tbd/<slug>-<hash>); handles new
and existing files (fetches the blob sha for updates).
- Adds head.sha + mergeable to the PR struct.
- Tests: full gate matrix (green/pending/red/cancelled/none/mixed/protected) +
Call happy-merge, no-CI fail-closed, red fail-closed, allowlist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dockerfile: GOPRIVATE and the http→https insteadOf rewrite now name
git.d-ma.be (was the pre-rename gitea.d-ma.be; masked at build time only by
GOPROXY=direct + GOSUMDB=off).
- .context/PROJECT.md Repo URL → git.d-ma.be, adapters regenerated
(CLAUDE.md, AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt).
- main.go: version is now a `-ldflags -X main.version` overridable var defaulting
to "dev" instead of a hardcoded, drifting "0.1.0".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The infra#179 partial_failure message and the tool descriptor told callers to
"Finalize locally with `hyperguild new-project`" — but that command does not
exist (the hyperguild CLI only has tier/brain/mode; it was specced, never built).
It pointed users at a dead end.
Extracted the message into a pure infra179FinalizeMessage() and reworded it to
name the actual remaining work — cloning the repo and substituting the leftover
__PROJECT_NAME__ / __MODULE_PATH__ placeholders, or retrying — with no reference
to any scaffolding CLI. Descriptor updated to match. Unit-tested the wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps mcp-chassis to v0.3.0, which adds structured audit logging on every auth
rejection and returns 503 temporarily_unavailable (not a silent 401) when Dex is
unreachable at validation time. Wires slog.SetDefault so those audit lines flow
through gitea-mcp's JSON handler.
Together with the earlier /healthz jwt-status reporting and startup degradation
warning, this closes#6 (Dex-down is now observable and distinct from a bad
token) and #9 (auth failures are audit-logged: reason, IP, token type, hashed
fingerprint — never the raw token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CallerMiddleware silently preferred X-Auth-Request-User over X-Forwarded-User
with no explanation and no signal when both were set. Documented the precedence
(X-Auth-Request-User is the verified OIDC identity oauth2-proxy sets, so it is
authoritative; X-Forwarded-User is a fallback), and it now takes a *slog.Logger
and warns when both headers are present and disagree, so a proxy
misconfiguration is visible instead of silently resolved. Table-driven tests
cover precedence (both/single/none) and the conflict-warning path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gitea host renamed (infra ADR-0004); the mcp-chassis dep already migrated
(3329ff3), so the sequencing gate is clear. `go mod edit -module` + bulk import
rewrite across all .go files. gitea-mcp is a server binary (not an imported
library), so no downstream consumers break. build + task check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tool already commits directly to any existing branch (BranchExists→upsert,
no create — covered by TestFileWriteBranchSkipsCreateWhenBranchExists), so
`branch:"main"` is a one-call direct-to-main write. The descriptor said "feature
branch", understating it. pr_create/pr_merge/repo_list already exist, closing the
other two #35 gaps (PR loop + owner repo listing). Descriptor now states both
paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An empty required repo identifier built a trailing-empty path segment
(`/api/v1/repos/{owner}/`) and leaked gitea's bare 404. parseArgs now
validates, via reflection, that `repo`/`name` string args are non-empty and
returns a typed ErrValidation naming the field. Optional identifiers (e.g.
code_search's owner-wide fan-out `repo`) opt out with `,omitempty`. `owner` is
already enforced by the allowlist check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pr_merge was the only tool still advertising `index` as its id field while
every other issue/PR tool uses the canonical `number` (#38). Flipped its
schema property + required + struct field/tag `index` -> `number`; the existing
`index`->`number` shim keeps legacy `index` callers working, so no shim change
was needed (no canonical-`index` tool remains). Now the id arg is `number`
uniformly across all per-issue/PR tools.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every caller (claude.ai connector, LLMs primed on gitea/GitHub) sends the repo
identifier as `repo`, but the 33 per-repo identifier tools advertised `name`.
The v0.2.8 shim aliased repo->name so it worked, yet the advertised inputSchema
still said `name` — a misleading contract, with the shim load-bearing.
- Flip all 33 identifier tools: schema property + required + struct field/tag
from `name` to `repo`. A compliant `repo` caller now matches the struct
directly; the shim is pure back-compat.
- normalizeAliases is now bidirectional (name<->repo), so legacy `name` callers
still resolve, and repo_create / create_project_from_template — whose `name`
means "name of the NEW repo", not an existing-repo id — keep `name` and still
accept `repo`.
- `number`/`index` left as-is (out of scope; separate pre-existing quirk where
pr_merge advertises `index` rather than `number`).
- Tests: schema-canonical assertion + flipped alias round-trip (explicit `repo`
wins over `name`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gitea's workflow_dispatch endpoint returns 204 No Content with no Location
header. DispatchWorkflow required that header, so every successful dispatch
errored with "missing Location header" and never yielded a run ID — making
the tool unusable for CAD dispatch.
- DispatchWorkflow now returns error-only; 204 = success, no Location needed.
Body already carried ref+inputs; kept and covered by test.
- Tool snapshots the newest existing workflow_dispatch run before dispatch,
then polls ListWorkflowRuns after and returns the newest run with ID above
that baseline (avoids returning a stale prior run). Falls back to an honest
"dispatched, run not yet registered" result rather than failing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional dispatch_allow bool (default false). When true, inject a
.dispatch-allow file at repo root on the resolved default branch after
substitution, marking the new project dispatch-eligible (dispatch#3)
without a manual follow-up commit.
Rides the existing upsertRetry path so injection inherits the infra#179
branch-readiness / partial_failure handling; a stalled injection degrades
exactly like substitution. Reported in files_substituted. false/omitted
is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gitea's template-generate is slow-async on this instance (repo not writable for
>40s; infra#179) — no synchronous MCP tool can wait that long, and the 60s retry
made the call hang until the client timed out. Bound the write-readiness retry to
5s (a healthy gitea commits in ~1s and this still catches it), and when the branch
isn't writable in time, return a clear partial_failure: repo created, substitution
deferred, finalize locally with `hyperguild new-project`. Substitution logic is
intact and completes automatically once generate is fast (infra#179). Tool
description updated to describe substitution as best-effort. Refs #42, infra#179.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BranchExists returns true before the generated branch is writable, so
waitForBranch didn't help and a 2.5s retry budget was too short (branch became
writable ~30s post-generate under load in live testing). Drop waitForBranch;
let upsertRetry be the gate — retry the write on the transient "branch does not
exist" not-found for up to 60s, early-exit on success. Once the first write
lands the branch is writable and the rest succeed immediately. Refs #42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live e2e surfaced a race unit tests couldn't (mocks are instant): gitea's
/generate returns and serves reads before the branch ref is writable, so the
first content writes 404 "branch does not exist" for a beat — aborting the
whole substitution pass. Add waitForBranch (poll BranchExists after generate)
+ upsertRetry (retry writes on the transient not-found). Test fake now serves
the branch readiness probe. Refs #42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image build's `go mod download` failed on the stale
gitea.d-ma.be/mathias/mcp-chassis import (the gitea→git rename; the server no
longer serves a matching go-import meta tag). This is the latent #74 breakage
flagged for gitea-mcp, triggered by the first clean rebuild since the rename
(the #42 push). Point at git.d-ma.be/mathias/mcp-chassis v0.2.0 + go mod tidy.
Unblocks deploying the #42 create_project_from_template fix. gitea-mcp's own
module path stays gitea.d-ma.be (main module, not fetched — separate cleanup).
Refs #74, #42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derived adapters drifted after the root ~/dev/.context/AGENT.md gained the
rule-0 pre-task ritual; task check's context:check gate failed on it
(pre-existing, unrelated to #42). Regenerate via context-sync.sh.
create_project_from_template returned files_substituted:null and produced a
non-building scaffold. Three root causes, all fixed:
1. Empty branch: /generate omits default_branch, so every SubstituteFile read
hit an empty ref and 404'd → nothing substituted. Resolve the branch
explicitly (re-fetch the repo; fall back to "main"). The old unit test hid
this by mocking default_branch:"main".
2. Incomplete + rename-incapable: substitution ran over a fixed 6-file list
that missed cmd/__PROJECT_NAME__/main.go and could not rename the
cmd/__PROJECT_NAME__/ directory. Replace with a recursive tree walk:
content-substitute every blob, and for any path carrying a placeholder,
rename it (POST-create new path + delete old).
3. Stale module host: __MODULE_PATH__ used gitea.d-ma.be (the pre-rename host,
which breaks `go mod download` downstream). Use git.d-ma.be.
Also: fail loud — if nothing was substituted, populate partial_failure instead
of returning silent success (the null that started this).
Tests rewritten to drive the tree-walk flow and assert: cmd/ rename (new path
POST + old path delete), git.d-ma.be module substitution, empty-generate-branch
fallback, and the loud-on-nothing path.
Closes#42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>