diff --git a/.env.example b/.env.example index 8e53a488b1..af0b5083c2 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,23 @@ GITTENSORY_REVIEW_DRAFT=false # # /setup returns 400; with it, /setup needs ?token= (or an # # x-setup-token / Bearer header) so a freshly-booted, not-yet-configured # # instance can't be driven through App creation by a random visitor. +# ADMIN_GITHUB_LOGINS=your-github-login # REQUIRED for control-panel ("operator") access. A comma- or +# # whitespace-separated allowlist of GitHub logins, case-insensitive. +# # FAIL-CLOSED: unset/empty means NOBODY can sign into the dashboard, +# # even the person who set up this instance — this is not a bug, add +# # your own login here right after first-run setup. Also exempts these +# # logins from the agent's own-PR auto-close rules (fleet-operator +# # identity) and lets them bypass per-repo MCP scope. +# MCP_READ_REPO_ALLOWLIST= # scopes the shared GITTENSORY_MCP_TOKEN identity's READ-only MCP +# # tools (repo context, issue quality, watch subscriptions) to these +# # owner/repo entries (comma/whitespace-separated). FAIL-CLOSED: +# # unset/empty grants no repo access. `*` or `all` is an explicit +# # escape hatch for full unscoped read access, incl. cross-repo +# # contributor/operator tools that have no single repo to scope against. +# MCP_ACTUATION_REPO_ALLOWLIST= # same fail-closed/wildcard model as MCP_READ_REPO_ALLOWLIST, but for +# # WRITE (merge/close/approve) MCP tools — kept separate so read and +# # write trust can differ. Unrelated to ADMIN_GITHUB_LOGINS, which +# # scopes dashboard sign-in, not the shared MCP token's per-repo reach. # PORT=8787 # DATABASE_PATH=/data/gittensory.sqlite # SQLite file on the mounted data volume; all migrations auto-apply # POSTGRES_PASSWORD=change-this-long-random-value # used by the --profile postgres / --profile pgbouncer services @@ -184,8 +201,16 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review # GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS=1200 # TTL for required-status branch protection reads. # GITHUB_METADATA_CACHE_TTL_SECONDS=600 # TTL for stable repo/user/installation metadata reads. # GITHUB_COMMIT_CACHE_TTL_SECONDS=900 # TTL for bare /commits/{ref} resolves; dedups the two hourly upstream ref→SHA reads. +# GITHUB_WEBHOOK_MAX_BODY_BYTES=1048576 # max accepted GitHub webhook body size, in bytes (default 1 MiB). +# # Larger deliveries are rejected before parsing. Raise only if you +# # see legitimate webhooks rejected for size (e.g. a `push` event +# # with a large `commits` array from a big force-push or merge); +# # most installs never need to touch this. # QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store # # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector. +# BROWSER_WS_ENDPOINT= # ws:// URL of a browserless/chrome instance for visual-review +# # screenshot capture. Unset = visual review is fully inert (no +# # screenshots, no error) — this feature is entirely optional. # DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/ # # manual) on ANY repo you review. Unset = no Discord notifications. # # Collection and schema are auto-created at startup. Off when unset. @@ -324,6 +349,11 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review # # merged decision. single = one reviewer's verdict (auto when 1). # AI_ON_MERGE=either # synthesis merge rule: either (block if EITHER reviewer flags) | # # both (block only when both do). Ignored unless AI_COMBINE=synthesis. +# AI_DAILY_NEURON_BUDGET=10000000 # daily spend cap (Cloudflare Workers AI "neurons") shared by AI +# # summaries, the free consensus-defect reviewer pair, and the +# # AI-slop scorer. Default 10,000,000/day; clamped to [0, 10000000]. +# # Only meters FREE Workers-AI calls — BYOK/provider calls above bill +# # the maintainer's own account and are not counted against this. # Ollama reviewer (AI_PROVIDER=ollama). Defaults: OLLAMA_AI_BASE_URL=http://localhost:11434/v1, # OLLAMA_AI_MODEL=llama3.1, no API key. Set the base URL to http://ollama:11434/v1 when using the compose # --profile ollama service. diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx index a241debfe1..59f4913ddf 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx @@ -48,18 +48,52 @@ function SelfHostingGithubApp() { ]} /> +

One-click App creation (recommended for a Direct App)

+

+ Before the App exists (no GITHUB_APP_ID set yet), the self-host serves a setup + wizard at GET /setup. It renders a form that POSTs a GitHub App{" "} + manifest — the exact permission and event set below, pre-filled — to GitHub's own + App-creation flow. GitHub creates the App with the correct configuration in one step and + redirects back to exchange credentials automatically; there is no manual permission + checklist to get right or wrong. The route is disabled once an App is configured, so it + can't rebind a live install. +

+ + "`} + /> + + Manual App creation (below) is still fully supported — for an air-gapped instance, a + stricter change-review process, or simply a preference for reviewing every permission by + hand before it exists. Whichever path you take, the resulting App needs the SAME + permissions: this doc's manual list is kept in sync with the wizard's manifest and checked + in CI, so the two can't silently drift apart. + +

Direct App permissions

    -
  • Pull requests: read/write.
  • -
  • Checks: read/write.
  • -
  • Issues: read/write.
  • -
  • Contents: read. Add write only if the self-host should merge.
  • +
  • Pull requests: write.
  • +
  • + Checks: write — the gate posts a check-run; checks: read alone 403s that + write (silently fails the first review with no obvious cause). +
  • +
  • Issues: write.
  • +
  • + Contents: write — required for BOTH merging and the auto-maintain{" "} + update_branch action. contents: read looks sufficient at + creation time but silently breaks auto-merge later with no error surfaced in the UI; there + is no lesser permission that keeps merge/update-branch working. +
  • Commit statuses: read.
  • Metadata: read.

- Events should include pull request, pull request review, push, issues, check suite, check - run, and status. + Events: pull request, pull request review, push, issues, check suite, check run, and status.

Direct App env

diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx index f1f96b91c1..60e9f87854 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx @@ -70,6 +70,25 @@ function SelfHostingSecurity() { +

Control-panel access

+

+ GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by{" "} + ADMIN_GITHUB_LOGINS — a comma- or whitespace-separated, case-insensitive + allowlist of GitHub logins. +

+ + + Unset or empty means NOBODY gets control-panel access — not even the person who just + finished setup. This is intentional, not a bug: add your own GitHub login here right after + first-run setup, or you will sign in successfully and see zero privileges with no + explanation. The same allowlist also exempts these logins from the agent's own-PR auto-close + rules and lets them bypass per-repo MCP scope (MCP_READ_REPO_ALLOWLIST /{" "} + MCP_ACTUATION_REPO_ALLOWLIST). + +

AI credential boundaries

CLI auth files can be readable by the runtime. Do not mount a prompt-readable Claude Code or diff --git a/test/unit/setup-wizard-docs-parity.test.ts b/test/unit/setup-wizard-docs-parity.test.ts new file mode 100644 index 0000000000..b934a509ef --- /dev/null +++ b/test/unit/setup-wizard-docs-parity.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buildManifest } from "../../src/selfhost/setup-wizard"; + +const DOCS_PATH = "apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx"; + +// docs.self-hosting-github-app.tsx claims its manual permission list "is generated from the identical +// source the wizard's manifest uses, so the two can never drift apart again" (#2542). That claim is only +// true if something actually catches drift — this test IS that something: it reads buildManifest's real +// default_permissions/default_events and asserts the docs page's prose still names every one of them at the +// right access level, so an editor who changes one without the other fails CI instead of shipping silently +// wrong setup instructions. +const PERMISSION_LABELS: Record = { + pull_requests: "Pull requests", + checks: "Checks", + issues: "Issues", + contents: "Contents", + statuses: "Commit statuses", + metadata: "Metadata", +}; + +describe("self-host GitHub App manifest <-> docs parity (#2542)", () => { + it("the docs page's manual permission list names every buildManifest permission at the correct access level", () => { + const manifest = buildManifest("https://example.com", "state") as { default_permissions: Record }; + const docsSource = readFileSync(DOCS_PATH, "utf8"); + + expect(Object.keys(manifest.default_permissions).sort()).toEqual(Object.keys(PERMISSION_LABELS).sort()); + for (const [key, level] of Object.entries(manifest.default_permissions)) { + const label = PERMISSION_LABELS[key]; + expect(label, `no docs label mapped for manifest permission "${key}" — add one to PERMISSION_LABELS`).toBeDefined(); + const pattern = new RegExp(`${label}:\\s*${level}\\b`, "i"); + expect(docsSource, `docs page missing or wrong access level for "${label}" (expected "${level}")`).toMatch(pattern); + } + }); + + it("the docs page's events sentence names every buildManifest default_event, and only those", () => { + // Scoped to the "Events: ...." sentence specifically — a bare substring search (e.g. for "status") + // would false-pass by matching inside unrelated text like "Commit statuses" elsewhere on the page. + const manifest = buildManifest("https://example.com", "state") as { default_events: string[] }; + const docsSource = readFileSync(DOCS_PATH, "utf8"); + const sentenceMatch = /Events:\s*([^.]+)\./.exec(docsSource); + expect(sentenceMatch, "docs page has no \"Events: ...\" sentence to check").not.toBeNull(); + const eventsSentence = sentenceMatch![1]!; + const docsEvents = eventsSentence + .split(",") + .map((s) => s.replace(/\band\b/i, "").trim().toLowerCase()) + .filter(Boolean); + + const manifestEvents = manifest.default_events.map((e) => e.replace(/_/g, " ")); + expect(docsEvents.sort()).toEqual(manifestEvents.sort()); + }); +});