diff --git a/README.md b/README.md index 13aa7af..9a016a6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **A live software-delivery product—and a case study in making coding agents accountable for verified work.** -[Explore the hosted product](https://forgeloop.hookerhillstudios.com) · [View the source](https://github.com/JRH89/ForgeLoop) +[Explore the hosted product](https://forgeloop.codefrontlabs.com) · [View the source](https://github.com/JRH89/ForgeLoop) ForgeLoop takes GitHub issues or feature specifications through planning, bounded agent work, integration, verification, and human review. Its central design principle is simple: an agent saying “done” is not evidence. A run becomes reviewable only when its configured checks and acceptance criteria have supporting results. @@ -18,7 +18,7 @@ Coding agents can produce patches, but reliable software delivery also needs saf 2. A planner turns the request into a validated dependency graph of bounded tasks. 3. Runners claim compatible work and execute it in isolated Git worktrees, using locally configured model credentials. 4. Changes are integrated, then repository-policy checks, acceptance criteria, and an independent review produce evidence. -5. Failed checks can route into a limited repair cycle. Passing work reaches a human approval boundary before GitHub publication; auto-merge is opt-in and checks the reviewed commit. +5. Failed checks can route into a limited repair cycle. Passing work follows the configured human-approval policy before GitHub publication; auto-merge is opt-in and checks the verified commit. Administrators can also manually ask a runner to scan a repository snapshot for evidence-backed issue proposals. Scans are read-only, may incur provider charges, and never create GitHub issues without an explicit review action. @@ -32,7 +32,7 @@ Administrators can also manually ask a runner to scan a repository snapshot for | ForgeLoop runner | Java desktop/CLI process near the repository; uses Git worktrees and Docker for execution, MCP for approved context/tools, and runner-local provider credentials. | | External services | GitHub App and OAuth for identity/repository events; configured hosted or local model providers for agent work. | -The control plane coordinates work; the runner handles repository checkout and execution. Repository context goes from the runner to the configured model provider, not through the control-plane database. The service stores workflow records, findings, usage, and evidence metadata. Provider data handling still applies to context sent to a selected model. +The control plane coordinates work; the runner handles checkout, execution, and model calls. The hosted service stores workflow records, issue text, findings, usage, and verification evidence. Optional detailed run records can also upload prompts, provider responses, and repository context as artifacts. Local execution is not an offline-only or zero-data-transfer promise; provider data handling still applies. ## Engineering decisions @@ -72,6 +72,9 @@ This is an evolving portfolio product, not a claim that every production launch ## Delivery checklist +Checklist reviewed October 15, 2026. Checked items are implemented or have the +specific evidence stated; they do not imply every production validation is complete. + ### Product and control plane - [x] GitHub sign-in and self-service account provisioning with organization-scoped membership and roles. @@ -79,6 +82,7 @@ This is an evolving portfolio product, not a claim that every production launch - [x] Optional per-repository label bypass and Issue Chat label application with explicit paid-work warnings. - [x] Optional issue-assignment gate for any assignee or a specified GitHub login; read-only intake diagnostics. - [x] Manual repository analysis proposals with bounded/redacted context, runner-local model calls, usage estimates, and administrator-approved issue creation. +- [x] AI issue chat with editable specifications, explicit issue publication, optional intake-label application, and conversation deletion. - [x] Persisted runs, task graphs, gates, acceptance criteria, audit history, approval, cancellation, bounded retries, and queue archive/restore. - [x] Organization policies for budgets, concurrency, allowed providers, human approval, and optional verified-check auto-merge. - [x] Usage views for model/provider spend estimates, tokens, run costs, pricing coverage, and comparisons. @@ -92,20 +96,30 @@ This is an evolving portfolio product, not a claim that every production launch - [x] Docker verification with pinned images, time/output limits, and policy-controlled network access. - [x] Permissioned MCP context/tool routing with runner-side command controls. - [x] Redacted lifecycle logs, provider usage records, checksummed verification evidence, screenshots, and filesystem/S3-compatible artifact storage. -- [x] Independent review and criterion-level evidence before a run reaches human review. +- [x] Independent review and criterion-level evidence before delivery, with human approval required only when configured. - [x] GitHub check and pull-request publication; guarded auto-merge is opt-in and validates checks and the expected commit. ### Product experience and engineering quality - [x] Responsive dashboard and public product pages, user documentation, SEO/social metadata, and structured data. - [x] Cross-platform desktop preview packaging and GitHub Release download discovery with SHA-256 verification. +- [x] Runner 1.0.12 preview published for Windows/macOS x64 and ARM64, plus Linux DEB, RPM, Arch, and portable archives for both architectures; all twelve package digests verified. +- [x] Navy-and-teal desktop redesign, rounded native icons, HiDPI branding, and local preview/window-control regression coverage. +- [x] Consolidated Settings, persistent repository expansion/banner preferences, concise download guidance, and OS-specific download selectors. - [x] Automated CI for Java modules, frontend checks, MCP checks, Windows setup, and disposable end-to-end workflows. +- [x] Live public downloads/release-feed smoke check for 1.0.12, separate from installer signing. +- [x] Linux server Compose deployment with loopback diagnostic ports and server-hosted Cloudflare ingress; physical reboot recovery remains unverified. -### Remaining validation +### Live validation and remaining launch gates -- [x] Complete a second-repository unattended issue-to-merge run ([October 9 evidence](docs/second-repository-validation-2026-10-09.md)); this does not close the operational production gates below. +- [x] Complete a live verified delivery and merge test: project-hub issue #6 / PR #7 merged after manual approval ([October 9 evidence](docs/second-repository-validation-2026-10-09.md#completion-and-unattended-follow-up)). +- [x] Complete a second-repository unattended issue-to-merge test: project-hub issue #10 / PR #11 merged automatically with human approval disabled ([successful validation](docs/second-repository-validation-2026-10-09.md#successful-unattended-validation)). Both delivery tests are complete; operational launch gates remain separate. - [ ] Validate a physical workstation/server reboot, tunnel recovery, runner heartbeat, and authenticated access. - [ ] Configure encrypted off-host backup retention and complete a restore drill on another host, including evidence objects. -- [ ] Complete staging burn-in, external alert delivery, and an explicit production go/no-go review. -- [ ] Sign Windows/macOS installers, notarize macOS packages, and verify published release assets in a public smoke test. +- [ ] Complete staging burn-in. +- [ ] Verify external alert delivery. +- [ ] Record an explicit production go/no-go review after the operational checks above. +- [ ] Sign Windows/macOS installers and notarize macOS packages; published previews remain unsigned. +- [ ] Publish the canonical-domain SEO migration and new authorization article, then verify live redirects, sitemap, canonical/social URLs, and images. Implementation and local checks alone do not close this item. +- [ ] Verify the new domain in Google Search Console and submit its canonical sitemap; search indexing and ranking are not established by build checks. - [ ] Enable and verify support email notifications. diff --git a/docs/cloudflare-tunnel.md b/docs/cloudflare-tunnel.md index 93786b6..715a9f3 100644 --- a/docs/cloudflare-tunnel.md +++ b/docs/cloudflare-tunnel.md @@ -1,10 +1,11 @@ # Linux server deployment with Cloudflare Tunnel This guide deploys the current single-host Docker Compose stack at -`https://forgeloop.hookerhillstudios.com`, with -`https://forgeloop.codefrontlabs.com` as an additional hostname on the same -Cloudflare Tunnel. The Hooker Hill Studios hostname remains the configured -canonical URL. Cloudflare Tunnel is the only public ingress. PostgreSQL, the +`https://forgeloop.codefrontlabs.com`, the canonical public URL. The legacy +`https://forgeloop.hookerhillstudios.com` hostname remains on the same tunnel +for existing runner and authentication connections; public GET/HEAD pages +redirect to the canonical host after the SEO configuration is deployed. +Cloudflare Tunnel is the only public ingress. PostgreSQL, the control-plane API, and the web UI publish diagnostic ports on loopback only; they are not directly reachable from the LAN or Internet. @@ -103,7 +104,7 @@ tunnel's internal service addresses do not change. ```dotenv FORGELOOP_SECURITY_MODE=github - FORGELOOP_PUBLIC_BASE_URL=https://forgeloop.hookerhillstudios.com + FORGELOOP_PUBLIC_BASE_URL=https://forgeloop.codefrontlabs.com CLOUDFLARED_CREDENTIALS_FILE=/etc/forgeloop/secrets/credentials.json ``` @@ -122,14 +123,13 @@ tunnel's internal service addresses do not change. not need. The GitHub OAuth callback is - `https://forgeloop.hookerhillstudios.com/login/oauth2/code/github`. The GitHub + `https://forgeloop.codefrontlabs.com/login/oauth2/code/github`. The GitHub App setup callback is - `https://forgeloop.hookerhillstudios.com/api/github/app/callback`; its webhook - URL is `https://forgeloop.hookerhillstudios.com/api/github/webhooks`. These - URLs do not change during the server move. Never commit `.env` or print its + `https://forgeloop.codefrontlabs.com/api/github/app/callback`; its webhook + URL is `https://forgeloop.codefrontlabs.com/api/github/webhooks`. Never commit `.env` or print its resolved contents with `docker compose config` when sharing diagnostics. - The Codefront Labs hostname is an alternate ingress. Add its OAuth callback - URL to the GitHub App configuration before signing in through that hostname. + Register the canonical callback before using it. Retain any legacy callback + still needed by existing sessions; public-page redirects do not rewrite OAuth or runner endpoints. 4. Install the tunnel credential with restrictive host permissions. Transfer it over SSH/SCP or retrieve it from your secret manager; do not put it in the Git @@ -233,9 +233,9 @@ multi-host deployment. From the server: ```bash -curl -fsSI https://forgeloop.hookerhillstudios.com/ -curl -sSI https://forgeloop.hookerhillstudios.com/oauth2/authorization/github -curl -i -X POST https://forgeloop.hookerhillstudios.com/api/github/webhooks \ +curl -fsSI https://forgeloop.codefrontlabs.com/ +curl -sSI https://forgeloop.codefrontlabs.com/oauth2/authorization/github +curl -i -X POST https://forgeloop.codefrontlabs.com/api/github/webhooks \ -H 'Content-Type: application/json' -d '{}' sudo ss -lnt ``` @@ -296,7 +296,7 @@ The GitHub App helper preserves the webhook secret and does not print it: ```powershell node .\scripts\Update-GithubAppWebhook.mjs ` - https://forgeloop.hookerhillstudios.com/api/github/webhooks + https://forgeloop.codefrontlabs.com/api/github/webhooks node .\scripts\Update-GithubAppWebhook.mjs --deliveries ``` diff --git a/docs/deferred-launch-checks.md b/docs/deferred-launch-checks.md index 3b7d519..ce0b499 100644 --- a/docs/deferred-launch-checks.md +++ b/docs/deferred-launch-checks.md @@ -1,6 +1,7 @@ # Deferred launch checks -Agreed on 2026-09-26; updated with October 9 validation evidence. Unchecked items +Agreed on 2026-09-26; reviewed October 15 against the October 9 merge and +second-repository validation evidence. Both delivery checks are complete. Unchecked items remain open; deferral is not a passing result. - [ ] **Off-host backup and restore — deferred.** Select encrypted, access-controlled diff --git a/docs/deployment-prerequisites.md b/docs/deployment-prerequisites.md index 6650a5d..77ddc40 100644 --- a/docs/deployment-prerequisites.md +++ b/docs/deployment-prerequisites.md @@ -11,7 +11,7 @@ The GitHub App install entry point is implemented. Before enabling live reposito These values are deployment secrets and must be supplied through the deployment secret manager, never committed to this repository. -For the current public deployment, configure the GitHub App setup URL as `https://forgeloop.hookerhillstudios.com/api/github/app/callback` and webhook URL as `https://forgeloop.hookerhillstudios.com/api/github/webhooks`. ForgeLoop signs the installation state for ten minutes and records the resulting installation only after the callback validates that state. Subscribe to `issues`, `pull_request`, `check_run`, and `check_suite` events. Pull request `closed` events with `merged: true` synchronize human merges into the run state. ForgeLoop also accepts GitHub's installation lifecycle deliveries to synchronize repository selection changes. Auto-merge has a scheduled reconciliation sweep as a fallback, but check events provide immediate response. +For the current public deployment, configure the GitHub App setup URL as `https://forgeloop.codefrontlabs.com/api/github/app/callback` and webhook URL as `https://forgeloop.codefrontlabs.com/api/github/webhooks`. ForgeLoop signs the installation state for ten minutes and records the resulting installation only after the callback validates that state. Subscribe to `issues`, `pull_request`, `check_run`, and `check_suite` events. Pull request `closed` events with `merged: true` synchronize human merges into the run state. ForgeLoop also accepts GitHub's installation lifecycle deliveries to synchronize repository selection changes. Auto-merge has a scheduled reconciliation sweep as a fallback, but check events provide immediate response. Use least-privilege repository permissions: **Contents — read/write**, **Checks — read/write**, **Issues — read/write**, **Pull requests — read/write**, and **Commit statuses — read-only**. GitHub adds **Metadata — read-only** automatically. Issues write is used to create AI-proposed issues and explicitly close an issue after its verified pull request merges; failed close attempts remain pending and are retried. After changing App permissions, repository owners must approve the updated installation before those operations can succeed. Auto-merge cannot operate without pull-request write access and cannot establish the full success boundary without check and commit-status read access. diff --git a/docs/desktop-preview-release-notes.md b/docs/desktop-preview-release-notes.md index 4ec2106..80403da 100644 --- a/docs/desktop-preview-release-notes.md +++ b/docs/desktop-preview-release-notes.md @@ -60,4 +60,4 @@ For updates, pause the runner, wait for current work to finish, close the app, and install the new version. Runner identity, settings and provider keys are stored outside the installation directory and are preserved during upgrades. -Downloads and setup: https://forgeloop.hookerhillstudios.com/app/runner-downloads +Downloads and setup: https://forgeloop.codefrontlabs.com/app/runner-downloads diff --git a/docs/github-user-authentication.md b/docs/github-user-authentication.md index 84d906e..8d73098 100644 --- a/docs/github-user-authentication.md +++ b/docs/github-user-authentication.md @@ -2,7 +2,7 @@ ForgeLoop uses one GitHub App for two separate trust relationships. Installation tokens authorize repository automation; the GitHub web OAuth flow authenticates human operators. A repository installation never creates an authenticated browser session. -Configure the GitHub App callback URL as `https://forgeloop.hookerhillstudios.com/login/oauth2/code/github`, with wildcard matching, device flow, and authorization-during-installation disabled. Supply `FORGELOOP_GITHUB_CLIENT_ID` and `FORGELOOP_GITHUB_CLIENT_SECRET` through the deployment secret manager. The Client ID is not the App ID. +Configure the GitHub App callback URL as `https://forgeloop.codefrontlabs.com/login/oauth2/code/github`, with wildcard matching, device flow, and authorization-during-installation disabled. Supply `FORGELOOP_GITHUB_CLIENT_ID` and `FORGELOOP_GITHUB_CLIENT_SECRET` through the deployment secret manager. The Client ID is not the App ID. The public root does not load tenant data. **Sign in with GitHub** redirects through GitHub with OAuth state managed by Spring Security, exchanges the code only on the server, resolves GitHub's immutable numeric user ID, and stores authentication in an HTTP-only, Secure, SameSite cookie. ForgeLoop never uses a GitHub username as an authorization identifier. diff --git a/docs/public-website.md b/docs/public-website.md index 33c8787..9de136e 100644 --- a/docs/public-website.md +++ b/docs/public-website.md @@ -26,14 +26,27 @@ browser checks verify that closing gap as well. - Existing landing-page headlines, paragraphs, capability descriptions, and CTAs are retained. Additional explanatory sections and FAQs follow the original copy. - `/about`, `/features`, `/how-it-works`, `/security`, `/docs`, `/contact`, `/blog`. -- Eleven original engineering articles live in `frontend/src/public/articles.ts`. +- Twelve engineering articles live in `frontend/src/public/articles.ts`. Publication dates reflect the actual publication date, not invented history. No fabricated customer stories, traffic claims, benchmarks, reviews, or ratings. The September 27 field note targets the specific question of recovering a self-hosted AI coding runner after a restart; it cites Docker and GitHub documentation and distinguishes tested reconnect behavior from deferred reboot proof. + The October 15 article covers GitHub App versus fine-grained PAT authorization + for AI coding agents, with official GitHub sources and a rollout checklist. + +The canonical public origin is `https://forgeloop.codefrontlabs.com`, centralized +in `content.ts` for canonical tags, social URLs, JSON-LD, robots, and sitemap. +Legacy-host public GET/HEAD pages redirect permanently on deployment; runner, +API, OAuth, and pairing endpoints remain compatible. Regression checks reject +the old origin in generated public HTML. See [the October 15 SEO audit](seo-audit-2026-10-15.md) +for findings, deployment boundaries, and Search Console follow-up. + - Shared header/footer links cover desktop and mobile. Every public page and the 404 page has a decorative image-backed hero. Informational text remains HTML. +- Public navbar branding uses a 50px mark and 28px wordmark (44px/25px on mobile). + Above 760px, footer navigation groups are centered within their grid columns, + with left-aligned headings/links. Console and mobile-footer styling is unchanged. - Each page and article now has unique artwork, responsive derivatives, and a matching social preview. See [hero prompts and asset map](public-hero-prompts.md). - All hero templates (including 404) apply a dark navy overlay below 1440px, diff --git a/docs/second-repository-validation-2026-10-09.md b/docs/second-repository-validation-2026-10-09.md index 50fda24..1f1d51c 100644 --- a/docs/second-repository-validation-2026-10-09.md +++ b/docs/second-repository-validation-2026-10-09.md @@ -1,6 +1,12 @@ # Second-repository live validation — 2026-10-09 -Status: BLOCKED; the end-to-end test did not pass. +Status: VALIDATED on October 9. Verified delivery merged as PR #7; the subsequent +unattended issue #10 / PR #11 test passed without a human approval action. See +[successful unattended validation](#successful-unattended-validation) for the +final outcome. The blocked attempts below are retained as the debugging history, +not the current status. + +## Initial attempt — blocked - Target: `JRH89/project-hub`, issue #6 (trim whitespace in file searches). - Run: `440074fe-e140-4c56-a5db-2209b69e4457`. diff --git a/docs/seo-audit-2026-10-15.md b/docs/seo-audit-2026-10-15.md new file mode 100644 index 0000000..9b19c8a --- /dev/null +++ b/docs/seo-audit-2026-10-15.md @@ -0,0 +1,108 @@ +# SEO audit — October 15, 2026 + +Canonical origin: **https://forgeloop.codefrontlabs.com**. +Scope: public website, crawlable HTML, domain consolidation, article discovery, +current setup links, and README evidence accuracy. Changes are local on +`feat/seo-canonical-domain-blog`; this report does not claim deployment or indexing. + +## Findings and corrections + +| Priority | Observed issue | Correction | +| --- | --- | --- | +| High | All 20 live sitemap pages advertised the legacy origin in canonical, sitemap, Open Graph, and social-image URLs; robots also advertised its sitemap. | Centralize the approved origin and assert it in generated HTML, JSON-LD, sitemap, and robots checks. | +| High | Both hostnames served public pages with HTTP 200 rather than consolidating visitors. | Permanent 308 redirect for legacy-host public GET/HEAD pages. Preserve path/query; exclude runner APIs, GraphQL, OAuth, webhooks, and pairing. | +| Medium | Setup guides, README product links, and new-runner defaults still used the legacy host. | Update active public/setup references. Existing saved runner endpoints and legacy tunnel ingress remain compatible. No installed runner or live OAuth setting was changed. | +| Medium | README combined signing with an already-completed public release smoke test; validation notes began with a stale BLOCKED status. | Split those gates and record both successful merge/second-repository tests, with links to final evidence. Preserve the failed attempts as history. | +| Medium | Shared blog bylines and publisher schema still credited Hooker Hill Studios. | Centralize Codefront Labs publisher identity and use it in bylines, footer links, and structured data; reject obsolete credit/links in generated HTML. | + +The read-only live audit reported **81 repeated origin mismatches**, not 81 +independent defects: four per page plus the robots sitemap. It also checked page +status, H1 count, unique titles/descriptions, JSON-LD presence, and a genuine 404. +The old-host redirect and origin correction are not live until deployment. + +## Existing strengths retained + +- Complete prerendered article/page HTML: no JavaScript required for reading. +- Unique titles/descriptions, one H1, crawlable internal links and breadcrumbs. +- Article-specific Open Graph/Twitter images and BlogPosting structured data. +- Responsive WebP heroes, self-hosted font, compressed assets and image budgets. +- Noindex console/support shells and real HTTP 404s for missing public pages. +- No fabricated reviews, ratings, customers, traffic, or performance claims. + +## New article and keyword rationale + +**GitHub App vs PAT for AI coding agents** +Route: `/blog/github-app-vs-pat-ai-coding-agents`. + +Primary long-tail phrase: “GitHub App vs PAT for AI coding agents.” Supporting +intent: “GitHub authorization for self-hosted coding runners” and “short-lived +GitHub installation tokens.” These are relevance/intent hypotheses, not measured +search-volume or ranking predictions. Existing posts cover issues-to-PR delivery, +verification, local execution/privacy, budgets, repair, worktrees, testing, +webhooks, and recovery—not a dedicated App-versus-PAT decision guide. + +The post includes a comparison table, permission inventory, expiry handling, +rollout checklist, official GitHub references, and a contextual setup CTA. It +distinguishes fine-grained PATs from classic tokens and accurately limits +ForgeLoop's privacy and grant-scope claims. It is linked from the blog index, +homepage's latest-article cards, sitemap, and related-topic links in its body. +Publication date reflects the client date, October 15; no historic date was invented. + +## Verification + +- Frontend lint, 96 unit tests, typecheck, production/prerender build and SEO + verification passed: **21 public pages and 12 articles**. +- New article browser check passed with JavaScript disabled at 390px and 1440px; + canonical/social metadata, blog discovery, comparison table, and no horizontal + page overflow checked. Desktop/mobile screenshots visually reviewed. +- Proposed configuration passed `nginx -t` using a temporary validation wrapper + in the existing Nginx image, without loading it into the active server. +- All 15 public-site browser checks passed against an isolated Nginx container, + including actual 308 responses, query preservation, operational-endpoint + exclusions, real 404s, no-JavaScript rendering, and automated accessibility. + Its loopback-only test port was not routed through Cloudflare. +- Redirect regression cases preserve operational endpoints and reject redirecting + POST requests. Public production responses were inspected read-only. +- Navbar branding is 50px/28px on desktop and 44px/25px on mobile; footer link + groups center within their columns above 760px while text remains left-aligned. + Responsive checks cover the breakpoint edges without changing the mobile footer. + +Recheck after deployment, from `frontend`: + +```sh +node scripts/audit-live-seo.mjs https://forgeloop.codefrontlabs.com +``` + +Also verify an old-host article returns 308 to the same new-host path, with its +query string preserved. Do not redirect existing runner/authentication endpoints +as part of public SEO consolidation. + +## Follow-up requiring external evidence + +1. Deploy, rerun the live audit, and verify the new article/social assets return 200. +2. Verify ownership in Google Search Console, submit the new canonical sitemap, + and inspect representative URLs. Review old-domain migration there; a local + build does not submit pages or prove Google's selected canonical. +3. Measure real-user Core Web Vitals/PageSpeed and monitor indexing/impressions. + No performance or search-traffic score was measured in this audit. +4. Expand content from actual query data rather than publishing near-duplicate + keyword variations. Keep signed installers, reboot/restore, alerts, and burn-in + separate from SEO readiness and successful issue-to-merge validation. + +Sources: [Google canonical guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), +[Google JavaScript SEO](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics), +[GitHub App versus alternatives](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app), +[installation tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app). + +## Generated artwork + +Built-in image generation, not CLI fallback. Inspected against the existing +webhook-intake and self-hosted-runner illustrations as style references. +Source: `frontend/src/assets/heroes/github-app-vs-pat-ai-coding-agents.png`. +Responsive assets: `frontend/public/images/github-app-vs-pat-ai-coding-agents-hero.webp`, +`-hero-small.webp`, and `-social-preview.jpg`. Reproducible encodings are generated +by `prepare-images.mjs`; the original PNG is retained in the repository. + +Final prompt: + +> Use case: stylized-concept. Create a NEW editorial hero for a ForgeLoop article 'GitHub App vs PAT for AI coding agents'. The two input images are STYLE AND COMPOSITION REFERENCES ONLY, not edit targets. Match their polished 3D glass-and-dark-metal render, deep near-black navy background, restrained cyan/ice-blue/teal edge light, reflective dark floor, strong clean silhouette, and generous empty dark negative space on the LEFT 45% for overlaid webpage title. Landscape panoramic 2:1 composition. On the RIGHT show a transparent cyan glass keycard with a minimal keyhole inset hovering over a small circular dark-metal platform, with a delicate incomplete luminous clock ring suggesting time-limited access. Three modest glass repository-like blocks behind it, two linked by thin blue paths, one unlinked: selected repository access rather than a universal key. Sophisticated realistic studio materials and precise bevels, calm lighting, sparse composition. No words, letters, numbers, UI, logo, watermark, padlock cliches with chains, or busy circuitry. Do not copy either reference subject. Final use: blog hero plus cropped social preview. diff --git a/frontend/e2e/public-site.spec.ts b/frontend/e2e/public-site.spec.ts index 9b7b7f0..5a21aec 100644 --- a/frontend/e2e/public-site.spec.ts +++ b/frontend/e2e/public-site.spec.ts @@ -11,6 +11,9 @@ test('public pages serve content and social metadata without JavaScript',async({ expect(response?.status(),path).toBe(200); await expect(page.locator('h1')).toHaveCount(1); await expect(page.locator('h1')).toBeVisible(); + await expect(page.locator('body')).not.toContainText('Hooker Hill Studios'); + await expect(page.locator('a[href*="hookerhillstudios.com"]')).toHaveCount(0); + if(path.startsWith('/blog/'))await expect(page.locator('.article-byline a')).toHaveAttribute('href','https://codefrontlabs.com'); await expect(page.locator('meta[property="og:image"]')).toHaveAttribute('content',/social-preview\.jpg$/); expect((await page.locator('main').innerText()).length).toBeGreaterThan(400); } @@ -135,7 +138,8 @@ test('about page omits the studio credit and footer has no studio link',async({p await page.goto('/about'); await expect(page.getByRole('heading',{name:'Built by Hooker Hill Studios',exact:true})).toHaveCount(0); await expect(page.locator('.footer-note a[href="https://hookerhillstudios.com"]')).toHaveCount(0); - await expect(page.locator('.footer-note')).toContainText('© 2026 Hooker Hill Studios.'); + await expect(page.locator('.footer-note')).toContainText('© 2026 Codefront Labs.'); + await expect(page.locator('.footer-note').getByRole('link',{name:'Codefront Labs',exact:true})).toHaveAttribute('href','https://codefrontlabs.com'); }); test('article cards open from their read action and every page has distinct artwork',async({page,request})=>{ diff --git a/frontend/e2e/seo-article.spec.ts b/frontend/e2e/seo-article.spec.ts new file mode 100644 index 0000000..252e7ae --- /dev/null +++ b/frontend/e2e/seo-article.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; + +test('legacy-host redirects preserve public paths and queries but exclude operational endpoints',async({request})=>{ + const headers={Host:'forgeloop.hookerhillstudios.com'}; + const result=await request.get('/blog/github-app-vs-pat-ai-coding-agents?source=guide',{headers,maxRedirects:0}); + expect(result.status()).toBe(308); + expect(result.headers().location).toBe('https://forgeloop.codefrontlabs.com/blog/github-app-vs-pat-ai-coding-agents?source=guide'); + const pairing=await request.get('/app/runner-connect',{headers,maxRedirects:0}); + expect(pairing.status()).toBe(200); + const api=await request.head('/api/runner/not-a-real-endpoint',{headers,maxRedirects:0}); + expect(api.status()).not.toBe(308); + const post=await request.post('/docs',{headers,maxRedirects:0}); + expect(post.status()).not.toBe(308); +}); + +test('larger public navbar brand stays balanced without enlarging footer branding',async({page})=>{ + await page.goto('/'); + for(const width of [320,390,1000,1001,1440]){ + await page.setViewportSize({width,height:900}); + const brand=page.locator('.public-nav>.brand'); + await expect(brand.locator('img')).toHaveCSS('width',width<=1000?'44px':'50px'); + await expect(brand.locator('strong')).toHaveCSS('font-size',width<=1000?'25px':'28px'); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + const rightControl=page.locator(width<=1000?'.menu-toggle':'.public-nav>.primary'); + const left=await brand.boundingBox(),right=await rightControl.boundingBox(); + expect(left!.x+left!.width).toBeLessThan(right!.x); + } + await expect(page.locator('.public-footer .brand img')).toHaveCSS('width','30px'); + await page.screenshot({path:'../evidence/public-site/larger-navbar-brand.png'}); +}); + +test('footer groups sit in the middle of their columns with left-aligned links',async({page})=>{ + await page.goto('/'); + const groups=page.locator('.public-footer>nav'); + for(const width of [390,760,761,1000,1440]){ + await page.setViewportSize({width,height:900}); + for(const group of await groups.all()){ + if(width>760)await expect(group).toHaveCSS('justify-self','center'); + else await expect(group).not.toHaveCSS('justify-self','center'); + if(width>760)await expect(group).toHaveCSS('text-align','left'); + } + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + } + await page.locator('.public-footer').screenshot({path:'../evidence/public-site/footer-column-centering.png'}); +}); + +test('authorization article is crawlable, linked, and readable at desktop and mobile widths',async({browser})=>{ + const context=await browser.newContext({javaScriptEnabled:false}); + const page=await context.newPage(); + const path='/blog/github-app-vs-pat-ai-coding-agents'; + await page.goto(path); + await expect(page.locator('h1')).toHaveText('GitHub App vs PAT for AI coding agents'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href','https://forgeloop.codefrontlabs.com'+path); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute('content','https://forgeloop.codefrontlabs.com'+path); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute('content',/codefrontlabs.com\/images\/github-app-vs-pat-ai-coding-agents-social-preview.jpg$/); + await expect(page.getByRole('heading',{name:'A first-run checklist for an AI coding runner'})).toBeVisible(); + await expect(page.getByRole('table')).toHaveCount(1); + for(const width of [390,1440]){ + await page.setViewportSize({width,height:900}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.screenshot({path:`../evidence/public-site/authorization-article-${width}.png`,fullPage:true}); + } + await page.goto('/blog'); + await expect(page.getByRole('link',{name:/GitHub App vs PAT for AI coding agents/})).toBeVisible(); + await context.close(); +}); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 412de8f..e742030 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,12 +2,18 @@ resolver 127.0.0.11 valid=10s ipv6=off; # One anonymous GitHub lookup per five minutes, shared by all download visitors. proxy_cache_path /var/cache/nginx/releases levels=1:2 keys_zone=releases:1m max_size=10m inactive=24h; +# Consolidate only public GET/HEAD pages. Keep legacy runner/API/OAuth URLs compatible. +map "$host:$request_method:$uri" $legacy_public_redirect { + default 0; + ~^forgeloop\.hookerhillstudios\.com:(GET|HEAD):(/$|/(features|how-it-works|about|docs|blog|contact|changelog|robots\.txt|sitemap\.xml)/?$|/blog/[^/]+/?$) 1; +} upstream control-plane { zone control_plane 64k; server control-plane:8090 resolve; } server { listen 80; + if ($legacy_public_redirect) { return 308 https://forgeloop.codefrontlabs.com$request_uri; } gzip on; gzip_vary on; gzip_min_length 1024; diff --git a/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero-small.webp b/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero-small.webp new file mode 100644 index 0000000..670ac35 Binary files /dev/null and b/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero-small.webp differ diff --git a/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero.webp b/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero.webp new file mode 100644 index 0000000..6fee662 Binary files /dev/null and b/frontend/public/images/github-app-vs-pat-ai-coding-agents-hero.webp differ diff --git a/frontend/public/images/github-app-vs-pat-ai-coding-agents-social-preview.jpg b/frontend/public/images/github-app-vs-pat-ai-coding-agents-social-preview.jpg new file mode 100644 index 0000000..267d56a Binary files /dev/null and b/frontend/public/images/github-app-vs-pat-ai-coding-agents-social-preview.jpg differ diff --git a/frontend/scripts/audit-live-seo.mjs b/frontend/scripts/audit-live-seo.mjs new file mode 100644 index 0000000..6946e6b --- /dev/null +++ b/frontend/scripts/audit-live-seo.mjs @@ -0,0 +1,40 @@ +import { JSDOM } from 'jsdom'; + +// Read-only deployment check. Fetch the requested host even if its sitemap advertises an old one. +const origin=new URL(process.argv[2]??'https://forgeloop.codefrontlabs.com').origin; +const issues=[]; +async function get(path){return fetch(origin+path,{signal:AbortSignal.timeout(20000)});} +const robots=await get('/robots.txt'); +if(!robots.ok||!(await robots.text()).includes(`Sitemap: ${origin}/sitemap.xml`))issues.push('robots: canonical sitemap missing'); +const sitemapResponse=await get('/sitemap.xml'); +if(!sitemapResponse.ok)throw new Error('Sitemap unavailable'); +const sitemap=await sitemapResponse.text(); +const locations=[...sitemap.matchAll(/([^<]+)<\/loc>/g)].map(match=>new URL(match[1])); +if(!locations.length)throw new Error('Sitemap has no public URLs'); +const titles=new Set(),descriptions=new Set(); +let pagesChecked=0; +for(const location of locations){ + const path=location.pathname,expected=origin+path; + if(location.origin!==origin)issues.push(`${path}: sitemap uses ${location.origin}`); + const response=await get(path),html=await response.text(); + const doc=new JSDOM(html).window.document; + const canonical=doc.querySelector('link[rel="canonical"]')?.href; + if(response.status!==200)issues.push(`${path}: HTTP ${response.status}`); + if(canonical!==expected)issues.push(`${path}: canonical ${canonical}`); + if(doc.querySelector('a[href*="hookerhillstudios.com"]')||/Hooker Hill Studios/.test(doc.body.textContent))issues.push(`${path}: obsolete publisher/link`); + if(doc.querySelector('meta[property="og:url"]')?.content!==expected)issues.push(`${path}: Open Graph URL mismatch`); + if(doc.querySelectorAll('h1').length!==1)issues.push(`${path}: expected one H1`); + const description=doc.querySelector('meta[name="description"]')?.content; + if(!doc.title||titles.has(doc.title))issues.push(`${path}: missing/duplicate title`); + if(!description||descriptions.has(description))issues.push(`${path}: missing/duplicate description`); + titles.add(doc.title);descriptions.add(description); + const image=doc.querySelector('meta[property="og:image"]')?.content; + if(!image||new URL(image).origin!==origin)issues.push(`${path}: social image uses wrong origin`); + const schema=doc.querySelector('script[type="application/ld+json"]')?.textContent; + try{JSON.parse(schema??'');}catch{issues.push(`${path}: missing/invalid structured data`);} + pagesChecked++; +} +const missing=await get('/not-a-real-forgeloop-seo-audit-page'); +if(missing.status!==404)issues.push(`Missing page returned ${missing.status}, expected 404`); +console.log(JSON.stringify({origin,pagesChecked,issueCount:issues.length,issues},null,2)); +if(issues.length)process.exitCode=1; diff --git a/frontend/scripts/nginx-audit.conf b/frontend/scripts/nginx-audit.conf new file mode 100644 index 0000000..fd917fb --- /dev/null +++ b/frontend/scripts/nginx-audit.conf @@ -0,0 +1,5 @@ +# Validation wrapper only; the application container's active configuration is untouched. +events {} +http { + include /tmp/forgeloop-seo-audit-site.conf; +} diff --git a/frontend/scripts/verify-seo.mjs b/frontend/scripts/verify-seo.mjs index 4e7b945..5865453 100644 --- a/frontend/scripts/verify-seo.mjs +++ b/frontend/scripts/verify-seo.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { JSDOM } from 'jsdom'; import { publicPaths, articles, SITE, metadata } from '../.prerender/prerender.js'; const titles=new Set(),descriptions=new Set(),heroes=new Set(); +assert.equal(SITE,'https://forgeloop.codefrontlabs.com','Canonical origin must remain the approved public domain'); for(const path of publicPaths){ const html=await readFile(path==='/'?'dist/index.html':`dist${path}.html`,'utf8'); const doc=new JSDOM(html).window.document; @@ -13,6 +14,9 @@ for(const path of publicPaths){ assert.equal(doc.querySelectorAll('h1').length,1,`${path}: one main heading`); assert.equal(doc.querySelector('link[rel="canonical"]').href,SITE+path); assert.equal(doc.querySelector('meta[property="og:title"]').content,title); + assert.equal(doc.querySelector('meta[property="og:url"]').content,SITE+path); + assert.equal(doc.querySelector('meta[name="robots"]').content,'index, follow, max-image-preview:large'); + assert(!/hooker\s*hill\s*studios/i.test(doc.body.textContent),`${path}: obsolete publisher`); assert.equal(doc.querySelector('meta[name="twitter:card"]').content,'summary_large_image'); const hero=metadata(path).hero; assert.equal(doc.querySelector('meta[property="og:image"]').content,SITE+hero.social); @@ -22,15 +26,23 @@ for(const path of publicPaths){ assert(!doc.querySelector('.landing-terminal'),`${path}: removed status strip`); const schema=JSON.parse(doc.querySelector('script[type="application/ld+json"]').textContent); assert.equal(schema['@context'],'https://schema.org'); + if(metadata(path).article){ + const posting=schema['@graph'].find(entry=>entry['@type']==='BlogPosting'); + assert.equal(posting.url,SITE+path); + assert.equal(posting.mainEntityOfPage['@id'],SITE+path+'#page'); + } assert(doc.querySelector('main').textContent.length>400,`${path}: real prerendered content`); for(const link of doc.querySelectorAll('a[href]')){ const href=link.getAttribute('href'); + const linkedHost=new URL(href,SITE).hostname; + assert.notEqual(linkedHost,'hookerhillstudios.com',`${path}: obsolete publisher link`); + assert.notEqual(linkedHost,'forgeloop.hookerhillstudios.com',`${path}: obsolete public link`); if(href.startsWith('/')&&!href.startsWith('/app')&&!href.startsWith('/oauth2')&&!href.startsWith('/support'))assert(publicPaths.includes(href.split('#')[0]),`${path}: broken local link ${href}`); } assert(doc.querySelector('.public-hero,.landing-hero'),`${path}: missing hero`); } assert(articles.length>=11,'Expected the original ten articles and a new field note'); -assert(articles.some(article=>article.published==='2026-09-27'),'Today\'s field note is missing'); +assert.equal(new Set(articles.map(article=>article.slug)).size,articles.length,'Article slugs must be unique'); for(const article of articles){assert(article.body.trim().split(/\s+/).length>=280,`${article.slug}: thin content`);assert(/^\d{4}-\d{2}-\d{2}$/.test(article.published),`${article.slug}: invalid publication date`);} assert((await stat('dist/images/delivery-hero.webp')).size<500_000,'Hero exceeds image budget'); assert((await readFile('dist/app-shell.html','utf8')).includes('noindex, nofollow')); @@ -38,4 +50,8 @@ assert((await readFile('dist/404.html','utf8')).includes('noindex, nofollow')); const sitemap=await readFile('dist/sitemap.xml','utf8'); assert.equal((sitemap.match(//g)||[]).length,publicPaths.length); assert(!sitemap.includes('/app')); +const sitemapDoc=new JSDOM(sitemap,{contentType:'text/xml'}).window.document; +for(const location of sitemapDoc.querySelectorAll('loc'))assert.equal(new URL(location.textContent).origin,SITE,'Sitemap origin must be canonical'); +const robots=await readFile('dist/robots.txt','utf8'); +assert(robots.includes(`Sitemap: ${SITE}/sitemap.xml`)); console.log(`SEO checks passed: ${publicPaths.length} pages; ${articles.length} substantive articles; metadata, schema, links, and image budgets.`); diff --git a/frontend/src/assets/heroes/github-app-vs-pat-ai-coding-agents.png b/frontend/src/assets/heroes/github-app-vs-pat-ai-coding-agents.png new file mode 100644 index 0000000..5ceff3d Binary files /dev/null and b/frontend/src/assets/heroes/github-app-vs-pat-ai-coding-agents.png differ diff --git a/frontend/src/docs/runner-setup.md b/frontend/src/docs/runner-setup.md index dcb4333..5fa60f0 100644 --- a/frontend/src/docs/runner-setup.md +++ b/frontend/src/docs/runner-setup.md @@ -327,7 +327,7 @@ $runnerJar = (Resolve-Path runner/target/runner-0.1.0.jar).Path $runnerRoot = Join-Path $env:LOCALAPPDATA 'ForgeLoopRunner' New-Item -ItemType Directory -Force -Path $runnerRoot | Out-Null $env:FORGELOOP_RUNNER_STATE_FILE = Join-Path $runnerRoot 'identity' -$controlPlane = 'https://forgeloop.hookerhillstudios.com' +$controlPlane = 'https://forgeloop.codefrontlabs.com' Copy-Item runner/provider-policy.example.json (Join-Path $runnerRoot 'provider-policy.json') ``` diff --git a/frontend/src/public/PublicLayout.tsx b/frontend/src/public/PublicLayout.tsx index 3b789ef..060b4e0 100644 --- a/frontend/src/public/PublicLayout.tsx +++ b/frontend/src/public/PublicLayout.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { brandIcon as favicon } from '../brand'; -import { REPOSITORY } from './content'; +import { PUBLISHER, REPOSITORY } from './content'; import { heroStyle } from './heroes'; import { BookOpen, CircleHelp, Download, FileText, Info, Layers, LifeBuoy, Menu, Shield, X } from 'lucide-react'; @@ -8,7 +8,7 @@ const links = [['Features','/features'],['How it works','/how-it-works'],['About const mobileIcons = [Layers, CircleHelp, Info, FileText, BookOpen, Download, Shield, LifeBuoy]; export function PublicHeader() { return
- ForgeLoop + ForgeLoop Sign in with GitHub @@ -21,7 +21,7 @@ export function PublicHeader() { } export function PublicFooter() { const groups=[['Product',[['Features','/features'],['How it works','/how-it-works'],['Security','/security'],['Open the console','/oauth2/authorization/github']]],['Resources',[['Download runner','/app/runner-downloads'],['Getting started','/docs'],['Field notes','/blog'],['Changelog','/changelog'],['User guide','/app'],['Release progress',`${REPOSITORY}#readme`]]],['Project',[['About','/about'],['Contact & support','/contact'],['Track support tickets','/support'],['GitHub',REPOSITORY],['Issue tracker',`${REPOSITORY}/issues`]]]] as const; - return
ForgeLoop

ForgeLoop turns GitHub issues into verified pull requests with AI coding agents. Coordinate tasks, run checks, and review delivery evidence using runners on your own infrastructure.

{groups.map(([title,items])=>)}

© 2026 Hooker Hill Studios. Desktop installers are development previews; signed public distribution remains a release milestone.

; + return
ForgeLoop

ForgeLoop turns GitHub issues into verified pull requests with AI coding agents. Coordinate tasks, run checks, and review delivery evidence using runners on your own infrastructure.

{groups.map(([title,items])=>)}

© 2026 {PUBLISHER.name}. Desktop installers are development previews; signed public distribution remains a release milestone.

; } export function PublicLayout({children}:{children:ReactNode}) { return
Skip to content
{children}
; diff --git a/frontend/src/public/PublicSite.tsx b/frontend/src/public/PublicSite.tsx index 384b7af..9fa3199 100644 --- a/frontend/src/public/PublicSite.tsx +++ b/frontend/src/public/PublicSite.tsx @@ -1,7 +1,7 @@ import LandingPage from '../LandingPage'; import MarkdownContent from '../MarkdownContent'; import { articles } from './articles'; -import { pages, REPOSITORY } from './content'; +import { pages, PUBLISHER, REPOSITORY } from './content'; import { PublicLayout, PublicHero } from './PublicLayout'; import { ArticleCards } from './ArticleCards'; import './public.css'; @@ -10,7 +10,7 @@ export default function PublicSite({path}:{path:string}) { if(path==='/')return ; if(path==='/support')return

Private customer support

Support center

Loading your support workspace…

; const article=articles.find(item=>path===`/blog/${item.slug}`); - if(article)return

By Hooker Hill Studios · Published · {Math.ceil(article.body.split(/\s+/).length/220)} min read

; + if(article)return

By {PUBLISHER.name} · Published · {Math.ceil(article.body.split(/\s+/).length/220)} min read

; const page=pages.find(item=>item.path===path); if(path==='/contact'&&page)return

Send a private support request

Submit a question, report a bug, or ask for help with your account. Include the expected behavior, actual result, your operating system, and steps to reproduce the issue. Do not include passwords, API keys, payment details, or sensitive repository contents.

You can submit without an account and save a private tracking link to read replies and check status. Signed-in users can also find their requests in My tickets. Email notifications are not enabled; use the support portal to follow your request. Response times are not guaranteed.

Already contacted us? Track a ticket or view My tickets.

; if(!page)return
Return home Read the setup guide
; diff --git a/frontend/src/public/articles.ts b/frontend/src/public/articles.ts index 09ba9a9..8714a65 100644 --- a/frontend/src/public/articles.ts +++ b/frontend/src/public/articles.ts @@ -3,6 +3,68 @@ import { PUBLISHED } from './content'; export type Article = { slug: string; title: string; description: string; category: string; published: string; body: string }; // Publication dates describe actual publication, never a manufactured project history. export const articles: Article[] = [ + {slug:'github-app-vs-pat-ai-coding-agents',title:'GitHub App vs PAT for AI coding agents',description:'Choose a GitHub App or personal access token for an AI coding runner. Compare repository scope, token lifetime, permissions, and a safe rollout checklist.',category:'GitHub authorization',published:'2026-10-15',body:` +For an AI coding agent that runs unattended across selected repositories, a **GitHub App is usually the better starting point**. For a short-lived personal experiment, a narrowly scoped personal access token (PAT) can be simpler. The decision is about who owns access, what the credential can do, and what happens when it expires—not which token is easiest to paste into a settings field. + +This guide compares GitHub authorization for self-hosted coding runners. It does not compare model API keys: those authorize requests to a model provider, not access to your GitHub repositories. + +## GitHub App vs personal access token: the short version + +| Decision | GitHub App installation | Fine-grained PAT | +| --- | --- | --- | +| Good starting use | A maintained integration serving repositories or teams | A personal script or bounded experiment | +| Access owner | An app installation | The GitHub user who created the token | +| Repository boundary | Repositories selected for the installation; tokens can be narrowed further | Selected repositories within the token's resource owner | +| Credential lifecycle | Generate short-lived installation tokens | Choose an expiry and plan replacement | +| Operational trade-off | More integration setup; explicit installation management | Less initial setup; user access and token rotation remain dependencies | + +GitHub [recommends Apps for long-lived integrations](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app). That does not mean every personal prototype needs an App. It means a token borrowed from a maintainer's account should not quietly become the permanent identity of a shared delivery service. + +## Start with repository scope, not a token field + +Write down the repositories the runner needs before granting access. Begin with one disposable or low-risk repository, not every repository in an organization. A successful clone is not enough: test that a repository outside the approved set is denied. + +Fine-grained PATs can also restrict repository access and permissions; they are not the same as classic PATs. Check GitHub's [current PAT limitations and organization policies](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) against the endpoints you need. Do not work around a denied action by defaulting to a broader classic token. + +For a hosted integration, distinguish browser sign-in from repository authorization. Logging in tells the service who the operator is. Installing an App determines which repositories the integration may access. Neither operation should implicitly authorize every issue to start paid agent work. + +## Plan for short-lived GitHub installation tokens + +An installation access token expires after one hour. GitHub allows the issuer to narrow its repository and permission scope within the installation's existing grant. See the [installation-token generation rules](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app). + +In a runner architecture, keep the App private key in the trusted integration service rather than copying it to every worker. A worker should receive only the credential needed for an authorized operation. Keep tokens out of model prompts, repository files, Git remote URLs saved on disk, and diagnostic exports. + +Test expiry as a normal operating condition. If checkout fails after an interruption, obtain a fresh authorized grant; do not retry the same expired credential indefinitely. Keep task ownership and retry limits separate from credential renewal so an authentication error cannot create duplicate work. + +## Ask for permissions per operation + +Use GitHub's [permission-selection guidance](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) and the documentation for each API endpoint. A useful review starts with this capability inventory: + +- **Read source:** checkout the authorized repository. +- **Publish a branch:** push only the candidate changes that the workflow permits. +- **Manage issues:** read a specification or explicitly publish a reviewed issue. +- **Manage pull requests:** open a PR and report its state. +- **Publish checks:** attach verification results to the exact tested commit. + +Do not infer that one permission covers the entire delivery lifecycle. Likewise, granting a capability does not mean enabling it automatically. Keep human approval, issue intake, branch protection, and merge policy as separate decisions. If you later add a permission, test the installation approval path before assuming existing customers have granted it. + +## A first-run checklist for an AI coding runner + +1. Select one repository with working tests and a reversible task. +2. Record the credential owner, repository scope, permissions, expiry, and revocation procedure. Never record the secret itself. +3. Prove that authorized checkout works and unauthorized repository access fails. +4. Exercise branch publication and PR creation separately from automatic merge. +5. Simulate expiry or revocation without making a model request. Confirm the runner stops or requests a fresh grant rather than widening access. +6. Run a small end-to-end task with an explicit budget. Review the candidate diff and test evidence before expanding access. + +These are acceptance checks, not a security certification. GitHub authorization limits repository access; it does not sandbox the runner host or prevent source context from reaching the configured model provider. Read [what stays local on a self-hosted AI runner](/blog/self-hosted-ai-runners) before choosing a machine for private code. + +## How this maps to ForgeLoop + +ForgeLoop uses a GitHub App installation to authorize selected repositories. An enrolled runner requests repository credentials through an active-task grant rather than asking you to paste a personal GitHub token into the desktop app. The task check controls grant issuance; the installation token still carries its granted GitHub scope. Your model-provider key remains a separate runner-local credential. The hosted control plane handles coordination and stores run metadata and evidence; local execution is not an offline-only or zero-data-transfer promise. + +If this matches your workflow, follow the [ForgeLoop setup guide](/docs), authorize one repository, and try a small issue before enabling unattended delivery. For the next boundary after authorization, read [reliable GitHub webhook intake](/blog/github-webhooks-reliable-intake) and [guarded auto-merge for AI pull requests](/blog/safe-auto-merge-ai-pull-requests). +`}, {slug:'self-hosted-ai-runner-restart-recovery',title:'Recover a Self-Hosted AI Coding Runner',description:'Use a practical restart recovery checklist for self-hosted AI coding runners: verify durable state, credentials, heartbeats, leases, and safe task resumption.',category:'Runner operations',published:'2026-09-27',body:` A self-hosted AI coding runner can disappear halfway through a task because a laptop sleeps, Docker restarts, or a host reboots. The dangerous assumption is that “the container is running again” means the work is safe to resume. Restart recovery has two separate questions: can the machine reconnect, and does the delivery system still know who owns the task? diff --git a/frontend/src/public/changelog.test.tsx b/frontend/src/public/changelog.test.tsx index 8d0bfe3..2d82139 100644 --- a/frontend/src/public/changelog.test.tsx +++ b/frontend/src/public/changelog.test.tsx @@ -9,6 +9,8 @@ it('publishes a discoverable milestone changelog with release references and SEO expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('What changed in ForgeLoop.'); expect(screen.getByRole('link', { name: /Runner 1.0.11 release/ })).toHaveAttribute('href', 'https://github.com/JRH89/ForgeLoop/releases/tag/desktop-v1.0.11-preview.1'); expect(screen.getByRole('link', { name: 'Changelog' })).toHaveAttribute('href', '/changelog'); + expect(screen.getByRole('link', { name: 'Codefront Labs' })).toHaveAttribute('href', 'https://codefrontlabs.com'); + expect(document.querySelector('.footer-note')).not.toHaveTextContent('Hooker Hill Studios'); expect(publicPaths).toContain('/changelog'); expect(metadata('/changelog').title).toContain('changelog'); }); diff --git a/frontend/src/public/content.ts b/frontend/src/public/content.ts index f93854d..280d37e 100644 --- a/frontend/src/public/content.ts +++ b/frontend/src/public/content.ts @@ -1,7 +1,8 @@ import {aboutGuide, featuresGuide, setupGuide, workflowGuide} from './guides'; import { changelogSections } from './changelog'; -export const SITE = 'https://forgeloop.hookerhillstudios.com'; +export const SITE = 'https://forgeloop.codefrontlabs.com'; +export const PUBLISHER = { name: 'Codefront Labs', url: 'https://codefrontlabs.com' } as const; export const REPOSITORY = 'https://github.com/JRH89/ForgeLoop'; export const PUBLISHED = '2026-09-26'; export type Section = { heading: string; body: string; points?: string[]; links?: {label:string;href:string}[] }; diff --git a/frontend/src/public/public.css b/frontend/src/public/public.css index 24bd861..e98ee16 100644 --- a/frontend/src/public/public.css +++ b/frontend/src/public/public.css @@ -43,6 +43,12 @@ .public-site{--nav-offset:98px} .public-site main[id],.public-site main [id]{scroll-margin-top:var(--nav-offset)} .public-nav>nav{justify-content:center}.public-nav>.primary{justify-self:end;margin-left:0} +.public-nav>.brand{gap:12px;justify-self:start;white-space:nowrap} +.public-nav>.brand img{width:50px;height:50px;flex-shrink:0;border-radius:12px} +.public-nav>.brand strong{font-size:28px;line-height:1.1} +@media(max-width:1000px){.public-nav>.brand img{width:44px;height:44px}.public-nav>.brand strong{font-size:25px}} +/* Center each compact link group in its column, without centering the text inside it. */ +@media(min-width:761px){.public-site .public-footer>nav{justify-self:center;text-align:left}} .public-site .public-footer{padding-inline:28px} .public-site .menu-toggle{display:none} .public-site .mobile-dropdown{display:none} diff --git a/frontend/src/public/seo.test.ts b/frontend/src/public/seo.test.ts new file mode 100644 index 0000000..138dbf4 --- /dev/null +++ b/frontend/src/public/seo.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest'; +import { SITE } from './content'; +import { articles } from './articles'; +import { metadata, publicPaths } from './seo'; +import nginx from '../../nginx.conf?raw'; + +it('redirects legacy public pages without redirecting runner or authentication requests',()=>{ + const pattern=nginx.match(/~(\S+) 1;/)?.[1]; + expect(pattern).toBeTruthy(); + const redirects=new RegExp(pattern!); + for(const method of ['GET','HEAD'])for(const path of ['/','/blog','/blog/github-app-vs-pat-ai-coding-agents','/docs','/robots.txt','/sitemap.xml']){ + expect(redirects.test(`forgeloop.hookerhillstudios.com:${method}:${path}`)).toBe(true); + expect(redirects.test(`forgeloop.codefrontlabs.com:${method}:${path}`)).toBe(false); + } + for(const path of ['/api/runner/heartbeat','/graphql','/oauth2/authorization/github','/login/oauth2/code/github','/api/github/webhooks','/app/runner-connect','/app/runner-downloads']){ + for(const method of ['GET','HEAD','POST'])expect(redirects.test(`forgeloop.hookerhillstudios.com:${method}:${path}`)).toBe(false); + } + expect(redirects.test('forgeloop.hookerhillstudios.com:POST:/blog')).toBe(false); +}); + +it('uses the approved canonical host for every public metadata graph',()=>{ + expect(SITE).toBe('https://forgeloop.codefrontlabs.com'); + for(const path of publicPaths){ + const info=metadata(path); + expect(info.url).toBe(SITE+path); + expect(JSON.stringify(info.jsonLd)).not.toContain('forgeloop.hookerhillstudios.com'); + expect(JSON.stringify(info.jsonLd)).not.toContain('Hooker Hill Studios'); + expect(info.jsonLd['@graph'].find(item=>item['@type']==='Organization')?.name).toBe('Codefront Labs'); + expect(info.jsonLd['@graph'].find(item=>item['@type']==='WebSite')?.url).toBe(SITE+'/'); + } +}); + +it('adds a distinct authorization article with matching social art and useful internal links',()=>{ + const article=articles.find(item=>item.slug==='github-app-vs-pat-ai-coding-agents')!; + expect(article.published).toBe('2026-10-15'); + expect(new Set(articles.map(item=>item.slug)).size).toBe(articles.length); + expect(article.body).toContain('## A first-run checklist'); + expect(article.body).toContain('https://docs.github.com/'); + expect(article.body).toContain('](/docs)'); + const info=metadata('/blog/'+article.slug); + const schema=info.jsonLd['@graph'].find(item=>item['@type']==='BlogPosting'); + expect(schema?.url).toBe(SITE+'/blog/'+article.slug); + expect(schema?.image).toEqual([SITE+'/images/'+article.slug+'-social-preview.jpg']); +}); diff --git a/frontend/src/public/seo.ts b/frontend/src/public/seo.ts index 9b6feaa..8bdc607 100644 --- a/frontend/src/public/seo.ts +++ b/frontend/src/public/seo.ts @@ -1,5 +1,5 @@ import { articles } from './articles'; -import { pages, SITE } from './content'; +import { pages, PUBLISHER, SITE } from './content'; import { heroFor } from './heroes'; export const publicPaths=[...pages.map(page=>page.path),...articles.map(article=>`/blog/${article.slug}`)]; @@ -10,10 +10,10 @@ export function metadata(path:string) { const title=path==='/support'?'Support center | ForgeLoop':article?`${article.title} | ForgeLoop`:page?.title??'Page not found | ForgeLoop'; const description=path==='/support'?'Track your ForgeLoop support requests, read replies, and follow ticket status securely.':article?.description??page?.description??'This page could not be found. Explore ForgeLoop features, documentation, and practical software delivery guides.'; const url=SITE+(path==='/'?'/':path); - const organization={'@type':'Organization','@id':`${SITE}/#organization`,name:'Hooker Hill Studios',url:`${SITE}/about`,logo:{'@type':'ImageObject',url:`${SITE}/icons/icon-192.png`,width:192,height:192}}; - const graph:Record[]=[organization,{'@type':'WebSite','@id':`${SITE}/#website`,name:'ForgeLoop',url:`${SITE}/`,publisher:{'@id':`${SITE}/#organization`}}, + const organization={'@type':'Organization','@id':`${SITE}/#organization`,name:PUBLISHER.name,url:PUBLISHER.url,logo:{'@type':'ImageObject',url:`${SITE}/icons/icon-192.png`,width:192,height:192}}; + const graph:Record[]=[organization,{'@type':'WebSite','@id':`${SITE}/#website`,name:'ForgeLoop',url:`${SITE}/`,inLanguage:'en',publisher:{'@id':`${SITE}/#organization`}}, {'@type':article?'WebPage':path==='/about'?'AboutPage':path==='/contact'?'ContactPage':path==='/blog'?'CollectionPage':'WebPage','@id':`${url}#page`,url,name:title,description,isPartOf:{'@id':`${SITE}/#website`}}]; - if(article)graph.push({'@type':'BlogPosting',headline:article.title,description,datePublished:article.published,dateModified:article.published,author:{'@type':'Organization',name:'Hooker Hill Studios',url:`${SITE}/about`},publisher:{'@id':`${SITE}/#organization`},mainEntityOfPage:{'@id':`${url}#page`},image:[`${SITE}/images/social-preview.jpg`],articleSection:article.category}); + if(article)graph.push({'@type':'BlogPosting','@id':`${url}#article`,url,headline:article.title,description,datePublished:article.published,dateModified:article.published,inLanguage:'en',author:{'@type':'Organization',name:PUBLISHER.name,url:PUBLISHER.url},publisher:{'@id':`${SITE}/#organization`},mainEntityOfPage:{'@id':`${url}#page`},image:[`${SITE}/images/social-preview.jpg`],articleSection:article.category}); if(path==='/')graph.push({'@type':'SoftwareApplication',name:'ForgeLoop',applicationCategory:'DeveloperApplication',operatingSystem:'Web; Windows; macOS; Linux',url:`${SITE}/`,description:page?.description}); if(path!=='/'&&(page||article))graph.push({'@type':'BreadcrumbList',itemListElement:[{'@type':'ListItem',position:1,name:'Home',item:`${SITE}/`},...(article?[{'@type':'ListItem',position:2,name:'Field notes',item:`${SITE}/blog`},{'@type':'ListItem',position:3,name:article.title,item:url}]:[{'@type':'ListItem',position:2,name:page?.heading,item:url}])]}); const hero=heroFor(page||article?path:'/not-found'); diff --git a/runner/install/Install-Runner.ps1 b/runner/install/Install-Runner.ps1 index dd8f1b5..1453c7d 100644 --- a/runner/install/Install-Runner.ps1 +++ b/runner/install/Install-Runner.ps1 @@ -15,7 +15,7 @@ if (-not (Test-Path -LiteralPath (Join-Path $root 'runner.jar'))) { throw 'Extra $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value icacls.exe $root /inheritance:r /grant:r "*${sid}:(OI)(CI)F" | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Could not restrict runner directory permissions.' } -$endpoint = Read-Host 'ForgeLoop URL (for example https://forgeloop.hookerhillstudios.com)' +$endpoint = Read-Host 'ForgeLoop URL (for example https://forgeloop.codefrontlabs.com)' $uri = [Uri]$endpoint if (-not $uri.IsAbsoluteUri -or ($uri.Scheme -ne 'https' -and -not ($uri.Scheme -eq 'http' -and $uri.IsLoopback)) -or $uri.UserInfo) { throw 'Use HTTPS, or HTTP on localhost for development.' } $endpoint = $endpoint.TrimEnd('/') diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java b/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java index fed8081..d9b7019 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java @@ -17,7 +17,7 @@ public final class DesktopRunner { private final Path directory; private final DesktopWorker worker; private final JFrame frame=new JFrame("ForgeLoop Runner"); - private final JTextField endpoint=new JTextField("https://forgeloop.hookerhillstudios.com"),name=new JTextField("My runner"),input=new JTextField(),output=new JTextField(); + private final JTextField endpoint=new JTextField("https://forgeloop.codefrontlabs.com"),name=new JTextField("My runner"),input=new JTextField(),output=new JTextField(); private final JComboBox model=new JComboBox<>(new String[]{"claude-sonnet-5"}); private final DesktopPages steps=new DesktopPages(); private final JPanel recoveryControls=DesktopLayout.stack(8); diff --git a/scripts/Build-ArchPackage.sh b/scripts/Build-ArchPackage.sh index 72ec09f..2c7581f 100644 --- a/scripts/Build-ArchPackage.sh +++ b/scripts/Build-ArchPackage.sh @@ -51,7 +51,7 @@ pkgver=$version pkgrel=1 pkgdesc='ForgeLoop self-hosted software delivery runner' arch=('$package_arch') -url='https://forgeloop.hookerhillstudios.com' +url='https://forgeloop.codefrontlabs.com' license=('unknown') options=('!strip') depends=('libsecret' 'gtk3' 'libx11' 'libxext' 'libxi' 'libxrender' 'libxtst' 'libxrandr' 'libxinerama' 'libxcursor' 'libxfixes' 'libxcb' 'fontconfig' 'freetype2')