syntaxai/tdd.md · commit 8f1dde2

Default agent repos to private; render through admin token

Source code on agent repos is theirs, not ours to publish. Repos
created via the registration flow now ship with private: true; the
two existing syntaxai repos (string-calc, content-service-api) were
flipped to private via the Forgejo admin API.

To keep the verdict pages working without leaking the source, every
Forgejo API fetch from server.ts now passes the admin token via a
new adminApiHeaders() helper:
- repo summary + commits in renderRepoView
- user lookup + repo list + per-repo commits in /agents/:name
- the user-list query in /agents

The judge's git clone embeds the admin token via http.extraheader
(applied to the clone request only — never persisted in the cloned
repo's config).

Visible on the repo page: a small "[private]" badge next to the
kata link. Clones without auth get 401; clones with the agent's
push token (or admin token) work as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-04 07:00:03 +01:00
parent
496ab61
commit
8f1dde278cffbb3787013327dee6d49fc341b3fc

3 files changed · +40 −9

modified src/forgejo.ts +4 −1
@@ -117,7 +117,10 @@ export const createRepoForUser = async (params: {
117117 body: JSON.stringify({
118118 name: params.name,
119119 description: params.description ?? "",
120- private: false,
120+ // Private by default — the source is the agent's, not ours to
121+ // publish. Verdicts still render on tdd.md via admin-mediated
122+ // API calls; clones require the agent's push token.
123+ private: true,
121124 // No auto_init: the agent's first push becomes the genuine initial
122125 // commit. An admin-authored "Initial commit" would muddle the phase
123126 // log and break attribution on the agent's repo page.
modified src/judge.ts +8 −1
@@ -215,8 +215,15 @@ const readCommits = async (cwd: string): Promise<CommitInfo[]> => {
215215 export const judge = async (owner: string, repo: string): Promise<Verdict> => {
216216 const cwd = mkdtempSync(join(tmpdir(), `judge-${owner}-${repo}-`));
217217 try {
218+ // Agent repos default to private. Authenticate via admin token in
219+ // an http.extraheader so the token isn't persisted in the cloned
220+ // repo's config (extraheader applies to the clone request only).
218221 const cloneUrl = `${FORGEJO_INTERNAL}/${owner}/${repo}.git`;
219- const cloneR = await runProc(["git", "clone", "--quiet", cloneUrl, "."], cwd, 30000);
222+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
223+ const gitArgs = adminToken
224+ ? ["-c", `http.extraheader=Authorization: token ${adminToken}`, "clone", "--quiet", cloneUrl, "."]
225+ : ["clone", "--quiet", cloneUrl, "."];
226+ const cloneR = await runProc(["git", ...gitArgs], cwd, 30000);
220227 if (cloneR.exitCode !== 0) {
221228 throw new Error(`clone failed: ${cloneR.stderr || cloneR.stdout}`);
222229 }
modified src/server.ts +28 −7
@@ -87,7 +87,7 @@ const renderAgentsIndex = async (): Promise<Response> => {
8787 const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
8888 if (adminToken) {
8989 const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, {
90- headers: { Authorization: `token ${adminToken}` },
90+ headers: adminApiHeaders(),
9191 });
9292 if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
9393 }
@@ -269,6 +269,15 @@ const hmacSha256Hex = async (secret: string, body: string): Promise<string> => {
269269 // exposing git.tdd.md externally.
270270 const FORGEJO_INTERNAL = process.env.FORGEJO_URL ?? "https://git.tdd.md";
271271
272+// Admin-token-authenticated headers for API calls. Agent repos are
273+// private by default; rendering the verdict page must still work. We
274+// proxy the data through the admin identity, never exposing the source
275+// or push protocol publicly.
276+const adminApiHeaders = (): HeadersInit => {
277+ const token = process.env.FORGEJO_ADMIN_TOKEN;
278+ return token ? { Authorization: `token ${token}` } : {};
279+};
280+
272281 const HOP_BY_HOP = [
273282 "host",
274283 "connection",
@@ -329,6 +338,7 @@ interface ForgejoRepoSummary {
329338 description: string;
330339 clone_url: string;
331340 empty: boolean;
341+ private: boolean;
332342 }
333343
334344 interface ForgejoCommit {
@@ -351,7 +361,7 @@ const relativeTime = (iso: string): string => {
351361
352362 const renderRepoView = async (owner: string, repo: string): Promise<Response> => {
353363 const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
354- const repoRes = await fetch(repoApi);
364+ const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
355365 if (repoRes.status === 404) {
356366 const html = await renderNotFound(`/${owner}/${repo}`);
357367 return htmlResponse(html, 404);
@@ -365,6 +375,7 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> =>
365375 }
366376 const info = (await repoRes.json()) as ForgejoRepoSummary;
367377 const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
378+ const isPrivate = info.private === true;
368379
369380 // The repo name is by convention the kata id. If the kata exists, the
370381 // header link is meaningful and we know the total step count.
@@ -380,7 +391,9 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> =>
380391
381392 let commits: ForgejoCommit[] = [];
382393 if (!info.empty) {
383- const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`);
394+ const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
395+ headers: adminApiHeaders(),
396+ });
384397 if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
385398 }
386399 const progress = computeProgress(commits);
@@ -415,6 +428,7 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> =>
415428 const kataLink = kataExists
416429 ? `[\`${repo}\` →](/games/${repo})`
417430 : `\`${repo}\``;
431+ const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : "";
418432
419433 const verdict = latestRun(owner, repo);
420434 const headSha = commits[0]?.sha ?? null;
@@ -463,7 +477,7 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> =>
463477 scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
464478 }
465479
466- const body = `# ${owner} · playing ${kataLink}
480+ const body = `# ${owner} · playing ${kataLink}${privateBadge}
467481
468482 > ${status}
469483 > **${stepCounter}** steps verified
@@ -561,7 +575,9 @@ ${url("https://tdd.md/leaderboard", "0.7")}
561575 "/agents/register": htmlResponse(REGISTER_HTML),
562576 "/agents/:name": async (req) => {
563577 const name = req.params.name;
564- const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`);
578+ const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, {
579+ headers: adminApiHeaders(),
580+ });
565581 if (userRes.status === 404) {
566582 const html = await renderPage({
567583 title: `${name} — agents — tdd.md`,
@@ -571,12 +587,17 @@ ${url("https://tdd.md/leaderboard", "0.7")}
571587 });
572588 return htmlResponse(html, 404);
573589 }
574- const reposRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`);
590+ const reposRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, {
591+ headers: adminApiHeaders(),
592+ });
575593 const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : [];
576594
577595 const progressByRepo = await Promise.all(
578596 repos.map(async (r) => {
579- const cRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`);
597+ const cRes = await fetch(
598+ `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`,
599+ { headers: adminApiHeaders() },
600+ );
580601 const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : [];
581602 return { repo: r, progress: computeProgress(commits) };
582603 }),