8f1dde278cffbb3787013327dee6d49fc341b3fc diff --git a/src/forgejo.ts b/src/forgejo.ts index 4f94d79f76d29bea8c75ad8f7968dea8e1f6cbc7..91c37d7fa34263cdc64fde5037f4e07440d995f5 100644 --- a/src/forgejo.ts +++ b/src/forgejo.ts @@ -117,7 +117,10 @@ export const createRepoForUser = async (params: { body: JSON.stringify({ name: params.name, description: params.description ?? "", - private: false, + // Private by default — the source is the agent's, not ours to + // publish. Verdicts still render on tdd.md via admin-mediated + // API calls; clones require the agent's push token. + private: true, // No auto_init: the agent's first push becomes the genuine initial // commit. An admin-authored "Initial commit" would muddle the phase // log and break attribution on the agent's repo page. diff --git a/src/judge.ts b/src/judge.ts index 96a50acafc297648dcdda25137bb528279fbc9e1..ebe57dd51fa8346e39b4f8f9264ed7ea3cb152b6 100644 --- a/src/judge.ts +++ b/src/judge.ts @@ -215,8 +215,15 @@ const readCommits = async (cwd: string): Promise => { export const judge = async (owner: string, repo: string): Promise => { const cwd = mkdtempSync(join(tmpdir(), `judge-${owner}-${repo}-`)); try { + // Agent repos default to private. Authenticate via admin token in + // an http.extraheader so the token isn't persisted in the cloned + // repo's config (extraheader applies to the clone request only). const cloneUrl = `${FORGEJO_INTERNAL}/${owner}/${repo}.git`; - const cloneR = await runProc(["git", "clone", "--quiet", cloneUrl, "."], cwd, 30000); + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; + const gitArgs = adminToken + ? ["-c", `http.extraheader=Authorization: token ${adminToken}`, "clone", "--quiet", cloneUrl, "."] + : ["clone", "--quiet", cloneUrl, "."]; + const cloneR = await runProc(["git", ...gitArgs], cwd, 30000); if (cloneR.exitCode !== 0) { throw new Error(`clone failed: ${cloneR.stderr || cloneR.stdout}`); } diff --git a/src/server.ts b/src/server.ts index c8e863cd5fec1f6b5bff57deff1edd541ba71910..840b858bfc67a5ff29eaf1c225fc0961ece484a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -87,7 +87,7 @@ const renderAgentsIndex = async (): Promise => { const adminToken = process.env.FORGEJO_ADMIN_TOKEN; if (adminToken) { const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, { - headers: { Authorization: `token ${adminToken}` }, + headers: adminApiHeaders(), }); if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; } @@ -269,6 +269,15 @@ const hmacSha256Hex = async (secret: string, body: string): Promise => { // exposing git.tdd.md externally. const FORGEJO_INTERNAL = process.env.FORGEJO_URL ?? "https://git.tdd.md"; +// Admin-token-authenticated headers for API calls. Agent repos are +// private by default; rendering the verdict page must still work. We +// proxy the data through the admin identity, never exposing the source +// or push protocol publicly. +const adminApiHeaders = (): HeadersInit => { + const token = process.env.FORGEJO_ADMIN_TOKEN; + return token ? { Authorization: `token ${token}` } : {}; +}; + const HOP_BY_HOP = [ "host", "connection", @@ -329,6 +338,7 @@ interface ForgejoRepoSummary { description: string; clone_url: string; empty: boolean; + private: boolean; } interface ForgejoCommit { @@ -351,7 +361,7 @@ const relativeTime = (iso: string): string => { const renderRepoView = async (owner: string, repo: string): Promise => { const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; - const repoRes = await fetch(repoApi); + const repoRes = await fetch(repoApi, { headers: adminApiHeaders() }); if (repoRes.status === 404) { const html = await renderNotFound(`/${owner}/${repo}`); return htmlResponse(html, 404); @@ -365,6 +375,7 @@ const renderRepoView = async (owner: string, repo: string): Promise => } const info = (await repoRes.json()) as ForgejoRepoSummary; const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; + const isPrivate = info.private === true; // The repo name is by convention the kata id. If the kata exists, the // header link is meaningful and we know the total step count. @@ -380,7 +391,9 @@ const renderRepoView = async (owner: string, repo: string): Promise => let commits: ForgejoCommit[] = []; if (!info.empty) { - const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`); + const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, { + headers: adminApiHeaders(), + }); if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[]; } const progress = computeProgress(commits); @@ -415,6 +428,7 @@ const renderRepoView = async (owner: string, repo: string): Promise => const kataLink = kataExists ? `[\`${repo}\` →](/games/${repo})` : `\`${repo}\``; + const privateBadge = isPrivate ? ` [private]` : ""; const verdict = latestRun(owner, repo); const headSha = commits[0]?.sha ?? null; @@ -463,7 +477,7 @@ const renderRepoView = async (owner: string, repo: string): Promise => scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`; } - const body = `# ${owner} · playing ${kataLink} + const body = `# ${owner} · playing ${kataLink}${privateBadge} > ${status} > **${stepCounter}** steps verified @@ -561,7 +575,9 @@ ${url("https://tdd.md/leaderboard", "0.7")} "/agents/register": htmlResponse(REGISTER_HTML), "/agents/:name": async (req) => { const name = req.params.name; - const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`); + const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, { + headers: adminApiHeaders(), + }); if (userRes.status === 404) { const html = await renderPage({ title: `${name} — agents — tdd.md`, @@ -571,12 +587,17 @@ ${url("https://tdd.md/leaderboard", "0.7")} }); return htmlResponse(html, 404); } - const reposRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`); + const reposRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, { + headers: adminApiHeaders(), + }); const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : []; const progressByRepo = await Promise.all( repos.map(async (r) => { - const cRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`); + const cRes = await fetch( + `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`, + { headers: adminApiHeaders() }, + ); const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : []; return { repo: r, progress: computeProgress(commits) }; }),