From 0e28b2125b3cd811bc55fa5914c4a0f0a6d53b9c Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 23 Jun 2026 10:47:00 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(gitea):=20WriteFile=20contents-API=20u?= =?UTF-8?q?psert=20=E2=80=94=20satisfies=20SummaryWriter=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Client.WriteFile (Gitea contents API) so the Gitea client also implements capture.SummaryWriter. Upserts: a GET resolves the current blob sha so an existing file is updated (the richer-fidelity-supersedes rule for re-captured sessions) rather than 422'd. Owner stays the fixed const; token only in the Authorization header (no leak — regression tested). Refactors the HTTP path into a shared request() helper so the contents flow can branch on 404 without it being an error. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/gitea/gitea.go | 109 ++++++++++++++++++++----- ingestion/internal/gitea/gitea_test.go | 64 +++++++++++++++ 2 files changed, 151 insertions(+), 22 deletions(-) diff --git a/ingestion/internal/gitea/gitea.go b/ingestion/internal/gitea/gitea.go index d54d1a7..a37ef16 100644 --- a/ingestion/internal/gitea/gitea.go +++ b/ingestion/internal/gitea/gitea.go @@ -12,6 +12,7 @@ package gitea import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -93,37 +94,101 @@ func (c *Client) CloseIssue(ctx context.Context, repo string, number int, commen return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil } -// do performs a JSON request against the Gitea API and decodes the -// response into out. Errors carry the status and a truncated body for -// diagnosis but never the token. -func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error { - reqBody, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody)) +// WriteFile creates or updates a file in repo at path via the Gitea +// contents API — the SummaryWriter port (#66). It upserts: a GET resolves +// the current blob sha (if any) so an existing file is updated rather than +// rejected (the richer-fidelity-supersedes rule for re-captured sessions). +// Owner is the fixed const, like every other call. +func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error { + cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) + sha, err := c.fileSHA(ctx, cpath) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - // Gitea's token scheme. Held here only; never logged. - req.Header.Set("Authorization", "token "+c.token) - - resp, err := c.http.Do(req) + payload := map[string]any{ + "message": "capture: " + path, + "content": base64.StdEncoding.EncodeToString([]byte(content)), + } + if sha != "" { + payload["sha"] = sha // update in place + } + status, body, err := c.request(ctx, http.MethodPut, cpath, payload) if err != nil { - return fmt.Errorf("gitea %s %s: %w", method, path, err) + return err } - defer func() { _ = resp.Body.Close() }() + if status < 200 || status >= 300 { + return fmt.Errorf("gitea PUT %s: status %d: %s", cpath, status, strings.TrimSpace(string(body))) + } + return nil +} - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody))) +// fileSHA returns the current blob sha for a contents path, or "" when the +// file does not exist (404). Any other non-2xx is an error. +func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) { + status, body, err := c.request(ctx, http.MethodGet, cpath, nil) + if err != nil { + return "", err } - if out != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, out); err != nil { + if status == http.StatusNotFound { + return "", nil + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body))) + } + var meta struct { + SHA string `json:"sha"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err) + } + return meta.SHA, nil +} + +// do performs a JSON request against the Gitea API and decodes a 2xx +// response into out. Errors carry the status and a truncated body for +// diagnosis but never the token. +func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error { + status, body, err := c.request(ctx, method, path, payload) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body))) + } + if out != nil && len(body) > 0 { + if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err) } } return nil } + +// request is the shared HTTP path: marshals an optional JSON payload, +// attaches auth (token only ever in the header), and returns the status + +// body so callers can branch on status (e.g. 404) without it being an +// error. Never logs the token. +func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) { + var reader io.Reader + if payload != nil { + reqBody, err := json.Marshal(payload) + if err != nil { + return 0, nil, fmt.Errorf("marshal request: %w", err) + } + reader = bytes.NewReader(reqBody) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "token "+c.token) + + resp, err := c.http.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + return resp.StatusCode, body, nil +} diff --git a/ingestion/internal/gitea/gitea_test.go b/ingestion/internal/gitea/gitea_test.go index 989dd4b..0f193f1 100644 --- a/ingestion/internal/gitea/gitea_test.go +++ b/ingestion/internal/gitea/gitea_test.go @@ -115,3 +115,67 @@ func TestErrorPathDoesNotLeakToken(t *testing.T) { assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message") assert.Contains(t, err.Error(), "500") } + +func TestWriteFileCreatesNewFile(t *testing.T) { + var getPath, putPath, putBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getPath = r.URL.Path + w.WriteHeader(http.StatusNotFound) // file does not exist yet + case http.MethodPut: + putPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + putBody = string(b) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}}) + } + })) + defer srv.Close() + + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), + "ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n") + require.NoError(t, err) + assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath) + assert.Equal(t, getPath, putPath) + // base64 of the content, no sha on create. + assert.Contains(t, putBody, "IyBTdW1tYXJ5") // base64("# Summary") + assert.NotContains(t, putBody, `"sha"`) +} + +func TestWriteFileUpdatesExisting(t *testing.T) { + var putBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"}) + case http.MethodPut: + b, _ := io.ReadAll(r.Body) + putBody = string(b) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}}) + } + })) + defer srv.Close() + + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new") + require.NoError(t, err) + assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha") +} + +func TestWriteFileErrorNoTokenLeak(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte("bad")) + })) + defer srv.Close() + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x") + require.Error(t, err) + assert.NotContains(t, err.Error(), testToken) + assert.Contains(t, err.Error(), "422") +} From 2368564523b6fa80e1076f9fc2921924d9192056 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 23 Jun 2026 10:47:00 +0200 Subject: [PATCH 2/3] fix(capture): wire ai-sessions SummaryWriter into the relay (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed CaptureService was constructed with a nil SummaryWriter, so a capture carrying a summary block returned the partial-failure "no summary writer configured" (surfaced in the 2026-06-23 claude.ai dogfood). The Gitea client already reaches mathias/* over BRAIN_GITEA_TOKEN and now implements SummaryWriter, so inject it (type-asserted from the tracker) — no new credential, no manifest change. Session summaries now write to mathias/ai-sessions at summaries///... Reuses the existing token deliberately; assumes it carries contents:write scope on ai-sessions (it already does issue writes for the tracker). If the token is issue-scoped only, the live write 422s — a token-scope widen, not a code fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/cmd/server/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 2294b32..d3a3f18 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -410,8 +410,12 @@ func main() { os.Exit(1) } auditSink := buildAuditSink(ctx, brainDir, logger) + // The Gitea client also satisfies SummaryWriter (#66): session + // summaries are written to mathias/ai-sessions over the same API + // token. nil only if a future tracker impl lacks file writes. + summaryWriter, _ := tracker.(capture.SummaryWriter) captureSvc := capture.NewService( - mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink) + mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink) sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS")) resolver := capturehttp.NewOriginResolver(sovereign) captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) From 5288554338324d820054546c5658edb17947225c Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 23 Jun 2026 17:22:37 +0200 Subject: [PATCH 3/3] fix(gitea): WriteFile creates via POST, updates via PUT (real contents API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live token-scope probe against mathias/ai-sessions revealed gitea's contents API uses POST to create and PUT (sha required) to update — the first impl always PUT'd, so creating a new summary 422'd "[SHA]: Required". The httptest mock had the same wrong assumption. Pick the method by whether the file exists (GET sha). Token confirmed contents:write (push:true) by the probe. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/gitea/gitea.go | 10 +++++++--- ingestion/internal/gitea/gitea_test.go | 16 +++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ingestion/internal/gitea/gitea.go b/ingestion/internal/gitea/gitea.go index a37ef16..a3a87e6 100644 --- a/ingestion/internal/gitea/gitea.go +++ b/ingestion/internal/gitea/gitea.go @@ -109,15 +109,19 @@ func (c *Client) WriteFile(ctx context.Context, repo, path, content string) erro "message": "capture: " + path, "content": base64.StdEncoding.EncodeToString([]byte(content)), } + // Gitea contents API: POST creates a new file, PUT updates an existing + // one (PUT requires the current sha). Pick by whether the file exists. + method := http.MethodPost if sha != "" { - payload["sha"] = sha // update in place + method = http.MethodPut + payload["sha"] = sha } - status, body, err := c.request(ctx, http.MethodPut, cpath, payload) + status, body, err := c.request(ctx, method, cpath, payload) if err != nil { return err } if status < 200 || status >= 300 { - return fmt.Errorf("gitea PUT %s: status %d: %s", cpath, status, strings.TrimSpace(string(body))) + return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body))) } return nil } diff --git a/ingestion/internal/gitea/gitea_test.go b/ingestion/internal/gitea/gitea_test.go index 0f193f1..3acca29 100644 --- a/ingestion/internal/gitea/gitea_test.go +++ b/ingestion/internal/gitea/gitea_test.go @@ -117,18 +117,20 @@ func TestErrorPathDoesNotLeakToken(t *testing.T) { } func TestWriteFileCreatesNewFile(t *testing.T) { - var getPath, putPath, putBody string + var getPath, postPath, postBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: getPath = r.URL.Path w.WriteHeader(http.StatusNotFound) // file does not exist yet - case http.MethodPut: - putPath = r.URL.Path + case http.MethodPost: // gitea contents API: POST = create + postPath = r.URL.Path b, _ := io.ReadAll(r.Body) - putBody = string(b) + postBody = string(b) w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}}) + default: + t.Errorf("create must POST, got %s", r.Method) } })) defer srv.Close() @@ -137,10 +139,10 @@ func TestWriteFileCreatesNewFile(t *testing.T) { "ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n") require.NoError(t, err) assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath) - assert.Equal(t, getPath, putPath) + assert.Equal(t, getPath, postPath) // base64 of the content, no sha on create. - assert.Contains(t, putBody, "IyBTdW1tYXJ5") // base64("# Summary") - assert.NotContains(t, putBody, `"sha"`) + assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary") + assert.NotContains(t, postBody, `"sha"`) } func TestWriteFileUpdatesExisting(t *testing.T) {