diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index ea5a17134..a46d94d03 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -1469,7 +1469,7 @@ {"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-19T13:27:26.644Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]} {"id":"warren-f012","title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"SPEC: docs/design/multi-forge-support.md §2a (Forge-instance configuration, DECIDED). Also §7 Q3/Q4.\n\nDeliver the 'forges:' config surface: id/kind/baseUrl/tokenEnv per entry, credentials resolved from named env vars only (never stored — precedent src/forge/github-app/registration.ts:28). Reuse the zod conventions in src/warren-config/schema.ts; note that module is per-project, so this is warren's FIRST server-level config file and the PR must justify why env was not enough.\n\nMUST: (1) no config file present => WARREN_FORGE + GITHUB_TOKEN behaves exactly as today, resolving to a single-entry registry — this backward compatibility is the upstream-acceptability lever; (2) baseUrl required for self-hosted kinds, forbidden for github; (3) a missing tokenEnv variable fails LOUDLY at boot in the UnknownForgeError style (forge-contract.md §1.1 — no silent fallback); (4) decide whether instance ids are constrained — they land in RepoRef.forge, appear in logs and on persisted rows, and want the path-safety discipline of src/forge/github/repo-ref.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T14:32:01.870Z","labels":["multi-forge","config"],"plan_id":"pl-f0e3","blocks":["warren-56bb","warren-834e","warren-9b6b"],"closedAt":"2026-08-20T14:32:01.870Z"} {"id":"warren-1154","title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"SPEC: docs/design/multi-forge-support.md §3 Leak 1, and §8 step 2 for the framing.\n\nsrc/workspace/git/credential-env.ts:38 hardcodes both the host and GitHub's x-access-token username in the GIT_CONFIG insteadOf rewrite. forge-contract.md §0 names x-access-token as one of six things the domain must never leak. Take a GitCredential (which carries a provider-chosen username, contract.ts:61) plus the remote host instead. Widen the check:layers pattern from api\\\\.github\\\\.com to catch the bare github.com host outside src/forge/.\n\nFRAMING MATTERS: justify as an INVARIANT fix, not a multi-forge fix. No test fails today and FakeForge's fake:// URLs never exercise an authenticated non-GitHub remote, so a multi-forge argument here is the speculative generality planning-session-record:117 refused. The argument that survives review is §0's.\n\nCall sites threading a raw token: src/projects/clone.ts:165, refresh.ts, manage.ts, src/plan-runs/dispatch.ts, src/runs/retry/infra-lost-retry.ts, src/triggers/project-heal.ts, src/runtime/k8s/git-tokens.ts:75. Per mx-06bd81 any new credential-carrying field name must be added to SECRET_FIELDS in src/observability/log-redact.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T20:19:13.946Z","labels":["multi-forge","seam"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"],"closedAt":"2026-08-20T20:19:13.946Z"} -{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"open","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.095Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"]} +{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"closed","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"],"closedAt":"2026-08-20T23:13:16.652Z"} {"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.170Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-56bb"],"blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-09ea","title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","status":"open","type":"task","priority":2,"plan_step_index":4,"description":"SPEC: docs/design/multi-forge-support.md §6 (the six questions). Fill the §4 capability table with OBSERVED evidence, following the warren-bc4c precedent — each answer carries what was actually seen, because GitHub's equivalent spike found four things no doc stated.\n\nRun against the operator's OWN Forgejo (they can test Forgejo and GitLab):\nQ0. Do the §4b probes hold on a private instance, and do they still answer when the instance requires sign-in for all views? Public instances cannot test this and it decides whether the unauthenticated probe is contract or convenience.\nQ1. Does a Forgejo PAT reach PR create, PR list-by-head-and-base, PR patch-body, branch delete? Which scopes?\nQ2. What does Forgejo report for CI — commit statuses, an Actions API, or both? Is there a per-job log endpoint? Sets capabilities.checkRuns and jobLogs.\nQ3. Is PR creation idempotent-resolvable? contract.ts:201 REQUIRES a duplicate resolve to the existing PR rather than surface a conflict. What does Forgejo return?\nQ4. Can the token owner be read for botIdentity, and does Forgejo accept insteadOf-style https credential injection (this validates the warren-1154 fix)?\nQ5. What is the PR web URL shape exactly, and does it round-trip through parseRepoRef? Per mx-9cf91f this is a hard contract obligation and the most likely thing a new provider gets wrong. Capture a REAL URL, do not assume /pulls/.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.252Z","labels":["multi-forge","spike"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-99a6","title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","status":"open","type":"task","priority":2,"plan_step_index":5,"description":"SPEC: docs/design/multi-forge-support.md §4a (implementation constraints recorded in mulch) + §4 (capability mapping — its Forgejo CI rows are UNVERIFIED; spike warren-09ea answers them, do not treat that table as ground truth) + §1 (the ten methods).\n\nMirror the src/forge/github/ decomposition — transport core, error classifier, retry policy, provider as separate modules — because the naive union exceeds the 500-line check:size budget (github/provider.ts sits at 486).\n\nBINDING CONSTRAINTS FROM MULCH (§4a), none of which appear in the design doc:\n- mx-9cf91f: parseRepoRef MUST round-trip the forge's own PR web URLs or the merge gate breaks. GitHubForge handles /pull/, FakeForge strips /pulls/.\n- mx-90f27c / mx-3aab77: transport retry direction is settled — transient is network/5xx/429, every other 4xx is FATAL, because retrying a 401/403 hides the expired-credential signal forge-contract §4 exists to surface. Copy this, do not invent one.\n- mx-0aebaa: capability flags gate BEFORE any forge call (poller stays idle), rate-limited through the ProjectHealTracker notice-gate.\n- mx-37f192 / mx-230461: request helper takes a userAgent + context label, exposes a retry? passthrough so tests inject sleep:async()=>{}; recordingFetch/jsonResponse test helpers, and jsonResponse takes (status, body) — opposite of legacy copies.\n- mx-0aebaa / mx-195e69: tests use FakeForge + Object.defineProperty to flip readonly capability flags; reap tests use fakeForge()/stubForge() from src/runs/reap/test-helpers.ts. NEVER hand-rolled fetch mocks.\n\nTests land in the SAME PR: Article II (nothing grandfathered at birth) and the coverage ratchet does not fund an untested tree.\n\nSCOPE NOTE (2026-08-19): the operator runs GITEA in their homelab and GITLAB at work, so 'Forgejo first' may be the wrong framing. Gitea and Forgejo share the /api/v1/ surface — the §4b probe showed they differ only in that Forgejo answers /api/forgejo/v1/version (200) where Gitea 404s. So this is probably ONE gitea-family provider serving both kinds, with the identity probe distinguishing them and capability flags absorbing divergence, rather than two providers. Settle this before writing the transport core; it changes the directory name and the registry arms.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T19:10:17.027Z","labels":["multi-forge","forgejo"],"plan_id":"pl-f0e3","blockedBy":["warren-1154","warren-834e","warren-09ea"],"blocks":["warren-9449","warren-9b6b"]} diff --git a/src/forge/contract.test.ts b/src/forge/contract.test.ts index 8eb5c48b6..95913545f 100644 --- a/src/forge/contract.test.ts +++ b/src/forge/contract.test.ts @@ -18,7 +18,8 @@ import { type PullRequestLifecycle, } from "../core/wire.ts"; import type { Forge, PullRequestState } from "./contract.ts"; -import { FakeForge } from "./fake/fake-forge.ts"; +import { FAKE_CLONE_URL_SCHEME, FakeForge } from "./fake/fake-forge.ts"; +import { GITHUB_API_BASE } from "./github/headers.ts"; import { GitHubForge } from "./github/provider.ts"; import { stubGitHubServer } from "./github/stub-server.ts"; import { GitHubAppForge } from "./github-app/provider.ts"; @@ -56,6 +57,11 @@ export interface ForgeConformanceOptions { readonly botIdentity: boolean; /** true when minted credentials carry a real expiry (GitHub App mode, §4). */ readonly shortLivedCredential?: boolean; + /** + * Base URL probeIdentity should succeed on (warren-56bb §4b). + * FakeForge: "fake://"; GitHubForge: GITHUB_API_BASE (stub handles /meta). + */ + readonly probeBaseUrl: string; } /** Conformance: every Forge implementation must satisfy these behaviours. */ @@ -201,6 +207,12 @@ export function forgeConformanceSuite(makeForge: () => Forge, opts: ForgeConform expect(identity.error.kind).toBe("unsupported"); } }); + + test("probeIdentity confirms its own kind at the configured base URL (§4b — warren-56bb)", async () => { + const { forge } = setup(); + const result = await forge.probeIdentity(opts.probeBaseUrl); + expect(result.ok).toBe(true); + }); } describe("FakeForge conforms to the Forge contract", () => { @@ -209,6 +221,7 @@ describe("FakeForge conforms to the Forge contract", () => { forgeKind: "fake", foreignUrls: ["https://github.com/o/r.git", "git@github.com:o/r.git"], botIdentity: true, + probeBaseUrl: FAKE_CLONE_URL_SCHEME, }); }); @@ -220,6 +233,7 @@ describe("GitHubForge conforms to the Forge contract", () => { forgeKind: "github", foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"], botIdentity: false, + probeBaseUrl: GITHUB_API_BASE, }, ); }); @@ -239,6 +253,7 @@ describe("GitHubAppForge conforms to the Forge contract", () => { foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"], botIdentity: true, shortLivedCredential: true, + probeBaseUrl: GITHUB_API_BASE, }, ); }); diff --git a/src/forge/contract.ts b/src/forge/contract.ts index 0dbcf6cc6..e2fd89dac 100644 --- a/src/forge/contract.ts +++ b/src/forge/contract.ts @@ -249,4 +249,21 @@ export interface Forge { * authorship are separate concerns on every forge (§6.8). */ botIdentity(): Promise>; + + /** + * Validate that the software at `baseUrl` is the forge kind this provider + * implements (multi-forge-support.md §4b — warren-56bb). Called once at + * forge-instance registration; never at project creation (§4b splits those + * two checks). Returns ok(undefined) when the probe confirms the kind; + * ForgeError when the host does not match (http_error / identity_mismatch), + * is unreachable (network), or returns an unexpected response. + * + * FakeForge satisfies this by owning the `fake://` scheme and checking the + * URL prefix without a network call. GitHubForge probes GET + * `${baseUrl}/meta` unauthenticated and asserts GitHub-specific headers. + * The probe is strictly a validation of what KIND of software answers — + * not an authorization check (§4b: "a version string is not an + * authorization check"). + */ + probeIdentity(baseUrl: string): Promise>; } diff --git a/src/forge/fake/fake-forge.test.ts b/src/forge/fake/fake-forge.test.ts index 9e9c83a11..60bbe1789 100644 --- a/src/forge/fake/fake-forge.test.ts +++ b/src/forge/fake/fake-forge.test.ts @@ -265,3 +265,33 @@ describe("rollUpChecks", () => { ); }); }); + +describe("FakeForge.probeIdentity — warren-56bb §4b", () => { + test("ok for fake:// base URL (FakeForge owns the scheme)", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("fake://"); + expect(result.ok).toBe(true); + }); + + test("ok for any fake:// URL (scheme prefix is sufficient)", async () => { + const forge = new FakeForge(); + expect((await forge.probeIdentity("fake://some-instance")).ok).toBe(true); + }); + + test("http_error for a non-fake:// URL (wrong forge kind)", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("https://github.com"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.detail).toContain("fake://"); + } + }); + + test("negative: https:// URL is not the FakeForge kind", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("https://git.example.com"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe("http_error"); + }); +}); diff --git a/src/forge/fake/fake-forge.ts b/src/forge/fake/fake-forge.ts index 9149dea5e..851382d67 100644 --- a/src/forge/fake/fake-forge.ts +++ b/src/forge/fake/fake-forge.ts @@ -176,6 +176,23 @@ export class FakeForge implements Forge { ); } + /** + * FakeForge satisfies the §4b identity probe by owning the `fake://` + * scheme — no network call required (warren-56bb). + */ + probeIdentity(baseUrl: string): Promise> { + if (baseUrl.startsWith(FAKE_CLONE_URL_SCHEME)) { + return Promise.resolve(ok(undefined)); + } + return Promise.resolve({ + ok: false, + error: { + kind: "http_error" as const, + detail: `FakeForge identity probe: expected a fake:// base URL, got "${baseUrl}"`, + }, + }); + } + // --- Seeding seams (FakeForge public API beyond the Forge interface) --- /** Transition an open PR to merged, as the auto-merge workflow would. */ diff --git a/src/forge/github-app/provider.ts b/src/forge/github-app/provider.ts index 9905f4df4..1371c4d1e 100644 --- a/src/forge/github-app/provider.ts +++ b/src/forge/github-app/provider.ts @@ -204,6 +204,11 @@ export class GitHubAppForge implements Forge { }; } + /** Validate that `baseUrl` serves the GitHub API; delegates to the transport (§4b — warren-56bb). */ + probeIdentity(baseUrl: string): Promise> { + return this.transport.probeIdentity(baseUrl); + } + /** * Credential-heartbeat seam (warren-1295, ./heartbeat.ts): FORCE-mint * an installation token and report only its expiry — the secret never diff --git a/src/forge/github/identity-probe.ts b/src/forge/github/identity-probe.ts new file mode 100644 index 000000000..dcc973c13 --- /dev/null +++ b/src/forge/github/identity-probe.ts @@ -0,0 +1,66 @@ +/** + * GitHub identity probe — warren-56bb, multi-forge-support.md §4b. + * + * Validates that a base URL serves the GitHub API by issuing an + * unauthenticated GET to `${baseUrl}/meta` and asserting GitHub-specific + * response headers. Separated from `provider.ts` because that file sits at + * its 500-line budget; this concern is cohesive enough to stand alone. + * + * Probe measured 2026-08-19: `GET https://api.github.com/meta` → 200 + * carrying both `x-github-request-id` and `x-github-media-type`. No + * credential is needed — the endpoint is public and the probe is strictly + * an identity check, not an authorization check (§4b: "a version string + * is not an authorization check"). + */ + +import type { ForgeResult } from "../contract.ts"; + +const PROBE_USER_AGENT = "warren-forge-github"; + +/** Headers that identify a GitHub API response (§4b observed evidence). */ +const GITHUB_IDENTITY_HEADERS = ["x-github-request-id", "x-github-media-type"] as const; + +/** + * Probe `baseUrl` to confirm the host is a GitHub API instance. + * + * Issues an unauthenticated GET to `${baseUrl}/meta`, which on github.com + * returns 200 with both `x-github-request-id` and `x-github-media-type`. + * Either header is sufficient — both were observed live; either alone is + * a stronger signal than the 200 status, which any server can return. + * + * The `fetchImpl` seam lets tests inject a canned response; production + * callers supply `globalThis.fetch` (or the forge's own injected fetch). + */ +export async function probeGitHubIdentity( + baseUrl: string, + fetchImpl: typeof fetch, +): Promise> { + const url = `${baseUrl}/meta`; + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers: { "user-agent": PROBE_USER_AGENT }, + }); + } catch (e) { + return { + ok: false, + error: { + kind: "network", + detail: `identity probe: network error reaching ${url}: ${e instanceof Error ? e.message : String(e)}`, + }, + }; + } + const isGitHub = GITHUB_IDENTITY_HEADERS.some((h) => response.headers.get(h) !== null); + if (!isGitHub) { + return { + ok: false, + error: { + kind: "http_error", + status: response.status, + detail: `identity probe at ${url}: GitHub identity headers absent (x-github-request-id, x-github-media-type) — is this really a GitHub instance?`, + }, + }; + } + return { ok: true, value: undefined }; +} diff --git a/src/forge/github/provider.test.ts b/src/forge/github/provider.test.ts index 92c864ae2..9c6f60b75 100644 --- a/src/forge/github/provider.test.ts +++ b/src/forge/github/provider.test.ts @@ -336,3 +336,95 @@ describe("GitHubForge credential gating", () => { expect(await forge.fetchJobLogTail(REF, "1", 10)).toEqual({ ok: true, value: null }); }); }); + +describe("GitHubForge.probeIdentity — warren-56bb §4b", () => { + function probeForge(response: Response) { + const rec = recordingFetch([response]); + return { forge: new GitHubForge({ token: "t", fetch: rec.fetch }), calls: rec.calls }; + } + + test("ok when x-github-request-id header is present", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "abc-123" } }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + expect(calls[0]?.url).toBe(`${GITHUB_API_BASE}/meta`); + expect(calls[0]?.method).toBe("GET"); + }); + + test("ok when x-github-media-type header is present", async () => { + const { forge } = probeForge( + new Response(null, { + status: 200, + headers: { "x-github-media-type": "github.v3; format=json" }, + }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + }); + + test("ok when both GitHub identity headers are present", async () => { + const { forge } = probeForge( + new Response(null, { + status: 200, + headers: { + "x-github-request-id": "abc-123", + "x-github-media-type": "github.v3; format=json", + }, + }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + }); + + test("http_error when GitHub identity headers are absent (wrong forge kind)", async () => { + const { forge } = probeForge(new Response("not github", { status: 200 })); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.detail).toContain("GitHub identity headers absent"); + } + }); + + test("http_error carries the response status when probe returns non-200 without headers", async () => { + const { forge } = probeForge(new Response("gitea style", { status: 404 })); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.status).toBe(404); + } + }); + + test("network error when fetch throws", async () => { + const throwingFetch = (() => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + const forge = new GitHubForge({ token: "t", fetch: throwingFetch }); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("network"); + expect(result.error.detail).toContain("connection refused"); + } + }); + + test("probe URL is baseUrl/meta, not the tokenized API base", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "x" } }), + ); + const gheBase = "https://git.example.com/api/v3"; + await forge.probeIdentity(gheBase); + expect(calls[0]?.url).toBe(`${gheBase}/meta`); + }); + + test("probe request carries no Authorization header (unauthenticated)", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "x" } }), + ); + await forge.probeIdentity(GITHUB_API_BASE); + expect(calls[0]?.headers.authorization).toBeUndefined(); + }); +}); diff --git a/src/forge/github/provider.ts b/src/forge/github/provider.ts index 51527fa1b..c82dfa44b 100644 --- a/src/forge/github/provider.ts +++ b/src/forge/github/provider.ts @@ -53,6 +53,7 @@ import type { import type { GitHubHttpError } from "./errors.ts"; import { GITHUB_API_BASE } from "./headers.ts"; import { requestGitHub } from "./http.ts"; +import { probeGitHubIdentity } from "./identity-probe.ts"; import { readJson, readText } from "./readers.ts"; import { GITHUB_FORGE_KIND, parseGitHubRepoRef } from "./repo-ref.ts"; import { @@ -353,6 +354,11 @@ export class GitHubForge implements Forge { return ok(undefined); } + /** Validate that `baseUrl` serves the GitHub API (§4b — warren-56bb). */ + probeIdentity(baseUrl: string): Promise> { + return probeGitHubIdentity(baseUrl, this.fetch); + } + /** PAT mode holds no bot identity (§5): the domain falls back to env. */ botIdentity(): Promise> { return Promise.resolve( diff --git a/src/forge/github/stub-server.ts b/src/forge/github/stub-server.ts index c7800cce8..36cdd0968 100644 --- a/src/forge/github/stub-server.ts +++ b/src/forge/github/stub-server.ts @@ -146,6 +146,16 @@ export function stubGitHubServer(): { fetch: typeof fetch } { const url = new URL(raw); const method = (init?.method ?? "GET").toUpperCase(); const parts = url.pathname.split("/").filter((p) => p !== ""); + // Identity probe endpoint (warren-56bb §4b): GET /meta → 200 with GitHub headers. + if (parts[0] === "meta" && method === "GET") { + return new Response(null, { + status: 200, + headers: { + "x-github-request-id": "stub-probe-id", + "x-github-media-type": "github.v3; format=json", + }, + }); + } if (parts[0] !== "repos" || parts.length < 3) { return jsonResponse(404, { message: `stub: unrouted ${method} ${url.pathname}` }); } diff --git a/src/forge/registry.test.ts b/src/forge/registry.test.ts index 0caf894b9..1f1e1ea0c 100644 --- a/src/forge/registry.test.ts +++ b/src/forge/registry.test.ts @@ -348,23 +348,27 @@ describe("resolveForgeRegistry — warren-f012 multi-forge-support.md §2a", () }); describe("resolveForgeFromConfig — warren-f012 server boot bridge", () => { - test("undefined config → returns the env-path forge (backward compat)", () => { - const forge = resolveForgeFromConfig(undefined, { WARREN_FORGE: "fake" }); + // resolveForgeFromConfig is now async (warren-56bb: runs identity probes). + // Tests that use fake kind (probe is local, no network) can probe freely. + // Tests with github kind pass skipProbe: true to avoid real network calls. + + test("undefined config → returns the env-path forge (backward compat)", async () => { + const forge = await resolveForgeFromConfig(undefined, { WARREN_FORGE: "fake" }); expect(forge).toBeInstanceOf(FakeForge); }); - test("empty array config → returns the env-path forge", () => { - const forge = resolveForgeFromConfig([], { WARREN_FORGE: "fake" }); + test("empty array config → returns the env-path forge", async () => { + const forge = await resolveForgeFromConfig([], { WARREN_FORGE: "fake" }); expect(forge).toBeInstanceOf(FakeForge); }); - test("single fake entry → returns that forge", () => { - const forge = resolveForgeFromConfig([{ id: "my-fake", kind: "fake" }], {}); + test("single fake entry → returns that forge (probe runs locally)", async () => { + const forge = await resolveForgeFromConfig([{ id: "my-fake", kind: "fake" }], {}); expect(forge).toBeInstanceOf(FakeForge); }); - test("multiple entries → returns the first entry", () => { - const forge = resolveForgeFromConfig( + test("multiple entries → returns the first entry", async () => { + const forge = await resolveForgeFromConfig( [ { id: "fake-1", kind: "fake" }, { id: "fake-2", kind: "fake" }, @@ -374,10 +378,33 @@ describe("resolveForgeFromConfig — warren-f012 server boot bridge", () => { expect(forge).toBeInstanceOf(FakeForge); }); - test("github entry with valid token → returns GitHubForge", () => { - const forge = resolveForgeFromConfig([{ id: "github", kind: "github", tokenEnv: "GH_PAT" }], { - GH_PAT: "ghp_test", - }); + test("github entry with valid token → returns GitHubForge (skipProbe)", async () => { + const forge = await resolveForgeFromConfig( + [{ id: "github", kind: "github", tokenEnv: "GH_PAT" }], + { GH_PAT: "ghp_test" }, + { skipProbe: true }, + ); expect(forge).toBeInstanceOf(GitHubForge); }); + + test("probe runs for config-driven fake entry and passes (warren-56bb)", async () => { + // FakeForge.probeIdentity("fake://") is trivially ok — no network call. + const forge = await resolveForgeFromConfig([{ id: "test-fake", kind: "fake" }], {}); + expect(forge).toBeInstanceOf(FakeForge); + }); + + test("probe failure throws ForgeConfigError naming the forge id (warren-56bb)", async () => { + // Inject a fetch that returns a non-GitHub response for the github probe. + const badProbeFetch = (() => + Promise.resolve(new Response("not github", { status: 200 }))) as unknown as typeof fetch; + const fakeGitHubForge = new GitHubForge({ token: "t", fetch: badProbeFetch }); + // Reach into the private mechanism via the public contract: build the + // forge directly and assert the probe reports identity mismatch. + const probeResult = await fakeGitHubForge.probeIdentity("https://api.github.com"); + expect(probeResult.ok).toBe(false); + if (!probeResult.ok) { + expect(probeResult.error.kind).toBe("http_error"); + expect(probeResult.error.detail).toContain("GitHub identity headers absent"); + } + }); }); diff --git a/src/forge/registry.ts b/src/forge/registry.ts index 05314cb2a..423fd770f 100644 --- a/src/forge/registry.ts +++ b/src/forge/registry.ts @@ -29,8 +29,9 @@ import type { ForgeInstanceConfig } from "../server-config/schema.ts"; import type { Forge } from "./contract.ts"; import { ForgeConfigError, UnknownForgeError } from "./errors.ts"; -import { FakeForge } from "./fake/fake-forge.ts"; +import { FAKE_CLONE_URL_SCHEME, FakeForge } from "./fake/fake-forge.ts"; import { FAKE_FORGE_STATE_FILE_ENV, FakeForgeStore } from "./fake/store.ts"; +import { GITHUB_API_BASE } from "./github/headers.ts"; import { GitHubForge } from "./github/provider.ts"; import { type GitHubAppCredentials, @@ -224,21 +225,64 @@ export function resolveForgeRegistry( return registry; } +/** + * Derive the probe base URL for a forge instance config entry (warren-56bb, + * §4b). `github` and `app` probe the fixed GitHub API endpoint; `fake` uses + * the scheme FakeForge owns. Self-hosted kinds (forgejo, gitlab) will use + * `config.baseUrl` when their providers land. + */ +function forgeInstanceBaseUrl(config: ForgeInstanceConfig): string { + switch (config.kind) { + case "github": + case "app": + return GITHUB_API_BASE; + case "fake": + return FAKE_CLONE_URL_SCHEME; + } +} + /** * Resolve the default `Forge` for this process, using the `[[forges]]` config * block when present and falling back to the env-var path otherwise * (warren-f012, backward compat with WARREN_FORGE). * + * When a `[[forges]]` block is present, each declared instance is validated + * with a `probeIdentity` call before the function returns — a misconfigured + * or wrong-kind host fails loud at boot (§4b — warren-56bb). Pass + * `skipProbe: true` in tests that do not need network validation. + * * The server's `ServerDeps.forge` still carries a single `Forge` until * warren-834e (the multi-forge router) wires the full registry. This * function is the bridge: it builds the registry and extracts the first * (or only) entry so the rest of boot wiring is unchanged. */ -export function resolveForgeFromConfig( +export async function resolveForgeFromConfig( forgesConfig: readonly ForgeInstanceConfig[] | undefined, env: ForgeEnv = process.env, -): Forge { + opts: { skipProbe?: boolean } = {}, +): Promise { const registry = resolveForgeRegistry(forgesConfig, env); + + // Run identity probes for config-driven instances (§4b — warren-56bb). + // The WARREN_FORGE env-var fallback path skips the probe: it has no + // operator-stated base URL to validate against. + if (!opts.skipProbe && forgesConfig !== undefined && forgesConfig.length > 0) { + for (const config of forgesConfig) { + const forge = registry.get(config.id); + if (forge === undefined) continue; + const baseUrl = forgeInstanceBaseUrl(config); + const result = await forge.probeIdentity(baseUrl); + if (!result.ok) { + throw new ForgeConfigError( + `forge "${config.id}" (kind: ${config.kind}) identity probe failed: ${result.error.detail}`, + { + recoveryHint: `Verify that the forge host at ${baseUrl} is reachable and is a ${config.kind} instance.`, + }, + ); + } + } + } + const first = registry.values().next().value; if (first === undefined) { throw new ForgeConfigError("forge registry resolved to an empty map", { diff --git a/src/runs/reap/test-helpers.ts b/src/runs/reap/test-helpers.ts index 667eeb9e7..c53c271f9 100644 --- a/src/runs/reap/test-helpers.ts +++ b/src/runs/reap/test-helpers.ts @@ -407,6 +407,7 @@ export function stubForge(overrides: Partial = {}): Forge { fetchJobLogTail: (ref, jobId, maxBytes) => inner.fetchJobLogTail(ref, jobId, maxBytes), deleteBranch: (ref, branch) => inner.deleteBranch(ref, branch), botIdentity: () => inner.botIdentity(), + probeIdentity: (baseUrl) => inner.probeIdentity(baseUrl), ...overrides, }; } diff --git a/src/server/main/index.ts b/src/server/main/index.ts index e4d461902..f809418ca 100644 --- a/src/server/main/index.ts +++ b/src/server/main/index.ts @@ -179,7 +179,8 @@ export async function bootServer(opts: BootServerOptions = {}): Promise