diff --git a/.env.example b/.env.example index a778e1c69..75dd8f9be 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,18 @@ GITHUB_EVENT_INTAKE_MODE=routing_websocket # are set, so you normally do not need this. # GH_AUTH_MODE=relay +# Advanced override for the GitHub user credential used only for visual-preview +# attachment uploads. An administrator's Web UI login can be captured when it +# uses a GitHub OAuth App token (`gho_`); GitHub's uploader rejects GitHub App +# user (`ghu_`) and installation (`ghs_`) tokens. Set this to override the Web +# UI flow with an OAuth App token, classic PAT, or fine-grained PAT for a user +# with write access to each target repository. +# GITHUB_VISUAL_PREVIEW_TOKEN=your_oauth_or_personal_access_token +# Optional dedicated encryption secret for the persisted OAuth grant. The +# default is SYSTEM_TASK_SECRET, falling back to SESSION_SECRET. Keep the value +# stable and identical for the API and worker. +# PROPR_CREDENTIAL_ENCRYPTION_KEY=generate-a-strong-secret-here + # --- Hosted UI tunnel (v1, optional) ----------------------------------------- # Expose this local stack's API to the hosted control plane at # https://app.propr.dev through a Cloudflare Tunnel, so you can drive a @@ -361,7 +373,7 @@ DASHBOARD_API_PORT=4000 # PROPR_ALLOW_INSECURE_LOCAL_WEB_PUSH=false # Per-client request quotas. Defaults protect the general API (600/minute), -# OAuth/session endpoints (30/15 minutes), and direct webhooks (300/minute). +# OAuth initiation/callback endpoints (30/15 minutes), and direct webhooks (300/minute). # Values must be positive integers; raise them only for measured trusted traffic. # PROPR_API_RATE_LIMIT_MAX=600 # PROPR_API_RATE_LIMIT_WINDOW_MS=60000 diff --git a/Dockerfile b/Dockerfile index 461cf64af..569793640 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM node:22-slim WORKDIR /usr/src/app +ARG GH_VERSION=2.99.0 +ARG TARGETARCH + # Install git, sudo, Docker tooling, and build tools for native modules # (better-sqlite3). Debian's essential bsdutils package already provides # script(1), used as the browser agent-login PTY bridge. @@ -12,9 +15,20 @@ RUN apt-get update && apt-get install -y \ git \ sudo \ docker.io \ + curl \ python3 \ make \ g++ \ + && gh_arch="${TARGETARCH:-amd64}" \ + && case "$gh_arch" in amd64|arm64) ;; *) echo "Unsupported GitHub CLI architecture: $gh_arch" >&2; exit 1 ;; esac \ + && gh_archive="gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${gh_archive}" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt" \ + && grep " ${gh_archive}$" "gh_${GH_VERSION}_checksums.txt" | sha256sum -c - \ + && tar -xzf "$gh_archive" \ + && install -m 0755 "gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh \ + && rm -rf "$gh_archive" "gh_${GH_VERSION}_checksums.txt" "gh_${GH_VERSION}_linux_${gh_arch}" \ + && gh --version \ && rm -rf /var/lib/apt/lists/* # Copy package files (including workspace packages) diff --git a/Dockerfile.agent b/Dockerfile.agent index 72bf495b8..ed01830f8 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -24,7 +24,7 @@ ARG CURL_VERSION_PREFIX=7.88.1-10+deb12u # install falls back to the latest available version when this prefix no longer # matches. That trades strict reproducibility for not breaking every build on a # gh release; the fallback logs a note to stderr when it triggers. -ARG GH_VERSION_PREFIX=2.96. +ARG GH_VERSION_PREFIX=2.99. ARG GIT_VERSION_PREFIX=1:2.39.5-0+deb12u ARG GOSU_VERSION_PREFIX=1.14-1 ARG IPTABLES_VERSION_PREFIX=1.8.9-2 diff --git a/Dockerfile.node b/Dockerfile.node index 19fc0c783..f56adf480 100644 --- a/Dockerfile.node +++ b/Dockerfile.node @@ -1,10 +1,23 @@ FROM node:22-alpine +ARG GH_VERSION=2.99.0 +ARG TARGETARCH + # Install git, sudo, Docker tooling, script(1) for browser agent-login PTYs, # curl, jq, and build tools for native modules (better-sqlite3) # curl and jq are required for deploy-pr.sh script execution # docker-cli-compose provides 'docker compose' (v2) command -RUN apk add --no-cache git sudo docker-cli docker-cli-compose curl jq util-linux-misc python3 make g++ +RUN apk add --no-cache git sudo docker-cli docker-cli-compose curl jq util-linux-misc python3 make g++ \ + && gh_arch="${TARGETARCH:-amd64}" \ + && case "$gh_arch" in amd64|arm64) ;; *) echo "Unsupported GitHub CLI architecture: $gh_arch" >&2; exit 1 ;; esac \ + && gh_archive="gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${gh_archive}" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt" \ + && grep " ${gh_archive}$" "gh_${GH_VERSION}_checksums.txt" | sha256sum -c - \ + && tar -xzf "$gh_archive" \ + && install -m 0755 "gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh \ + && rm -rf "$gh_archive" "gh_${GH_VERSION}_checksums.txt" "gh_${GH_VERSION}_linux_${gh_arch}" \ + && gh --version WORKDIR /usr/src/app diff --git a/docker-compose.yml b/docker-compose.yml index 908b2caf6..fa7229c9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -109,6 +109,9 @@ services: - OPENCODE_CONFIG_PATH=${HOME:?HOME must be set}/.config/opencode - VIBE_CONFIG_PATH=${HOME:?HOME must be set}/.vibe - PROPR_MANAGED_CREDENTIALS_DIR=${HOME:?HOME must be set}/.propr/agent-credentials + # Optional override for the encrypted OAuth credential collected through + # the Web UI. App installation tokens cannot upload GitHub attachments. + - GITHUB_VISUAL_PREVIEW_TOKEN=${GITHUB_VISUAL_PREVIEW_TOKEN:-} - PROPR_CONTAINERIZED=1 depends_on: redis: diff --git a/docker/Dockerfile.app.prod b/docker/Dockerfile.app.prod index db3766a42..29c22d511 100644 --- a/docker/Dockerfile.app.prod +++ b/docker/Dockerfile.app.prod @@ -48,6 +48,9 @@ FROM node:22-alpine AS runtime WORKDIR /usr/src/app +ARG GH_VERSION=2.99.0 +ARG TARGETARCH + # Runtime-only packages. No python/make/g++ — native modules were built in stage 1. # git — simple-git operations # sudo — worktree ownership changes @@ -55,6 +58,16 @@ WORKDIR /usr/src/app # curl/jq — deploy-pr.sh script # util-linux-misc — script(1), the PTY bridge for browser agent logins RUN apk add --no-cache git sudo docker-cli curl jq tini util-linux-misc \ + && gh_arch="${TARGETARCH:-amd64}" \ + && case "$gh_arch" in amd64|arm64) ;; *) echo "Unsupported GitHub CLI architecture: $gh_arch" >&2; exit 1 ;; esac \ + && gh_archive="gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${gh_archive}" \ + && curl -fsSLO "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt" \ + && grep " ${gh_archive}$" "gh_${GH_VERSION}_checksums.txt" | sha256sum -c - \ + && tar -xzf "$gh_archive" \ + && install -m 0755 "gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh \ + && rm -rf "$gh_archive" "gh_${GH_VERSION}_checksums.txt" "gh_${GH_VERSION}_linux_${gh_arch}" \ + && gh --version \ && mkdir -p /tmp/git-processor \ && git config --system --add safe.directory /usr/src/app/repos \ && git config --system --add safe.directory /tmp/pr-worktrees \ diff --git a/docs/docs/features/overview.md b/docs/docs/features/overview.md index feb17ba0b..5ddd207b6 100644 --- a/docs/docs/features/overview.md +++ b/docs/docs/features/overview.md @@ -41,6 +41,7 @@ Use different coding agents without changing the rest of the workflow. ProPR keeps follow-up work where the review already happens: the pull request. - [PR automation and fine-tuning](./pr-followup.md): create pull requests automatically, then refine them through natural GitHub comments or slash-command workflows. +- [Visual previews](./visual-previews.md): attach focused image or video evidence when an implementation changes something users can see. - [PR slash commands](./pr-commands.md): the command reference for `/review`, `/fix`, `/merge`, `/switch`, `/use`, and `/ultrafix`. - [Branch configuration](./branch-config.md): repository-specific branch defaults and resolution rules. diff --git a/docs/docs/features/pr-followup.md b/docs/docs/features/pr-followup.md index b2bc01a5d..aed0dabe0 100644 --- a/docs/docs/features/pr-followup.md +++ b/docs/docs/features/pr-followup.md @@ -16,6 +16,7 @@ When ProPR finishes an implementation task, it handles the GitHub plumbing aroun - Pushes to GitHub - Opens a pull request linked to the source issue - Posts status back to GitHub +- Attaches focused [visual previews](./visual-previews.md) when the repository enables them and the change has a visible result - Updates task and label state (`-processing` → `-done`, or `-failed-*` on failure) This keeps the agent focused on code while ProPR handles the repeatable workflow around the code. diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md index 5ccb54d7f..f16973ddc 100644 --- a/docs/docs/features/propr-cli.md +++ b/docs/docs/features/propr-cli.md @@ -254,6 +254,8 @@ propr repo remove owner/repo propr repo toggle owner/repo --enable # Enable/disable monitoring propr repo toggle owner/repo --auto-ci-followup # Enable failed-CI follow-up propr repo toggle owner/repo --no-auto-ci-followup # Disable failed-CI follow-up +propr repo toggle owner/repo --visual-previews --preview-types image,video +propr repo toggle owner/repo --no-visual-previews propr repo index owner/repo # Full reindex propr repo index owner/repo --incremental # Incremental reindex propr repo status # Indexing status for all repos @@ -261,6 +263,8 @@ propr repo status # Indexing status for all repos Automatic CI follow-up is configured per repository and is **off by default**. Enable it only for repositories whose CI failures are high-quality, trusted signals; noisy or flaky checks can otherwise create unnecessary follow-up work. `propr repo list` shows the current setting for every monitored repository. +Visual previews are also per-repository and **off by default**. `--preview-types` accepts `image`, `video`, or `image,video`; use `--preview-instructions` to add project-specific capture details. See [Visual Previews](./visual-previews.md) for generation and publication behavior. + ## Agents ```bash diff --git a/docs/docs/features/visual-previews.md b/docs/docs/features/visual-previews.md new file mode 100644 index 000000000..716d4cd85 --- /dev/null +++ b/docs/docs/features/visual-previews.md @@ -0,0 +1,87 @@ +--- +title: Visual Previews +--- + +# Visual Previews + +Visual previews let a ProPR implementation show its user-visible result directly in the generated pull request. The same policy applies to later follow-up commits, whose completion comments can include fresh media focused on that follow-up. + +The feature is opt-in per repository. Existing repository configurations remain disabled after an upgrade. + +GitHub attachment uploads require a GitHub OAuth App token (`gho_`) or personal +access token. GitHub's uploader rejects both GitHub App user (`ghu_`) and +installation (`ghs_`) tokens even though those tokens work for normal GitHub API +operations. When an instance administrator's Web UI login is backed by a GitHub +OAuth App, ProPR automatically stores its compatible credential, encrypted in +the shared database. Open **Settings → Visual preview uploads** to see which +account is connected or explicitly replace it with the current administrator +login. Normal GitHub API, commit, and pull-request operations continue to use +the GitHub App installation token. + +When normal Web UI login uses a GitHub App, an administrator can instead paste +a personal access token in **Settings → Visual preview uploads**. ProPR validates +the token with GitHub and encrypts it before storing it. No CLI, callback URL, or +service restart is required. For a fine-grained token, choose the organization or +user that owns the repositories as the resource owner, include every +preview-enabled repository, and grant the repository permission **Pull requests: +Read and write**. GitHub adds read-only metadata access automatically; no other +repository permission is required. The token owner must have push access to the +repositories and must complete any organization approval or SAML SSO authorization. +Fine-grained tokens can target only one resource owner. If the repositories span +multiple owners, use a classic PAT with `repo`, or `public_repo` if every repository +is public. Settings links to GitHub's token form with the fine-grained permission +preselected. + +Expiring OAuth credentials are refreshed on API startup and every 30 minutes +while the stack is running. Each successful refresh rotates the access and +refresh tokens, so an administrator does not need to sign in every six months +while the stack can keep refreshing them. A revoked grant, an expired unused +refresh token, or a changed encryption secret requires a fresh administrator +login. Personal access tokens are not refreshable OAuth grants; replace a +revoked or expired PAT in **Settings → Visual preview uploads**. As an advanced +server-managed alternative, configure `GITHUB_VISUAL_PREVIEW_TOKEN` with an +OAuth App token, classic PAT, or fine-grained PAT belonging to a user with write +access to every preview-enabled repository. + +`propr setup` also reuses an upload-compatible token from an existing `gh` CLI +session when no working preview credential is already configured. GitHub CLI +does not expose a refresh token to ProPR, so an expired or revoked imported token +must be replaced in Settings or re-imported by running setup again. + +## Configure A Repository + +On **Repositories**, turn on **Visual previews** beneath the repository entry. Choose **Images**, **Videos**, or both, then optionally add capture instructions such as: + +```text +Capture separate desktop and mobile views. Open the new settings dialog and focus the changed controls. +``` + +The setting is repository-wide. If the same repository has entries for multiple base branches, ProPR keeps their preview policy synchronized. + +The CLI exposes the same policy: + +```bash +propr repo add owner/repo --visual-previews --preview-types image,video \ + --preview-instructions "Capture desktop and mobile views." +propr repo toggle owner/repo --visual-previews --preview-types image +propr repo toggle owner/repo --no-visual-previews +``` + +## What The Agent Captures + +When enabled, the implementation agent evaluates the completed change: + +- If the result is perceptible visually, it captures the changed state with relevant project tooling such as Playwright, Storybook, a browser, an emulator, or a project-native renderer. +- If the change has no visible result, it does not create placeholder media. +- Captures focus on the change rather than generic application screens and must not contain credentials, personal data, or unrelated content. +- If capture is blocked, the agent can recommend the concrete browser, emulator, or media tool that should be added to the agent image. + +Agents generate files under the transient `.propr/previews/` runtime directory. Optional titles, descriptions, and tool recommendations are recorded in `.propr/previews/manifest.json`. Before committing, ProPR copies accepted files to worker-owned temporary storage and removes the runtime directory from the worktree. Preview files are therefore never included in the implementation commit. + +Supported image formats are PNG, JPEG, GIF, SVG, and WebP. Supported video formats are MP4, MOV, and WebM; H.264 MP4 is the most broadly compatible choice. Each attachment must be smaller than 10 MB. + +## Publication And Upload Failures + +ProPR publishes previews as [GitHub attachments](https://cli.github.com/manual/gh_pr_edit) so images render inline and videos use GitHub's media presentation. For follow-ups, it uploads the media first and then updates the existing progress comment; it does not create a temporary second comment. ProPR verifies that every temporary local path was replaced with a hosted attachment URL, then deletes the temporary files. If upload or verification fails, ProPR publishes a text-only explanation; preview media is not added to Git as a fallback. When the failure is a missing, unsupported, expired, or rejected user credential, that explanation includes the exact Settings reconnection steps in the affected pull request. + +Preview generation is evidence, not a replacement for automated tests. A preview failure does not discard an otherwise valid implementation; the PR explains missing tool support when the agent can identify it. diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md index 831a1333b..f05c506bb 100644 --- a/docs/docs/features/web-ui.md +++ b/docs/docs/features/web-ui.md @@ -45,7 +45,7 @@ These records are the heart of ProPR's observability — see [Observability And ## Repositories -**Repositories** (`/repositories`) manages the repos ProPR monitors — add, alias, set a base branch, enable/disable, reindex, hide, or delete. The selected repository opens a panel with four tabs: +**Repositories** (`/repositories`) manages the repos ProPR monitors — add, alias, set a base branch, enable/disable, configure [visual previews](./visual-previews.md), reindex, hide, or delete. Visual preview controls select image/video evidence and optional capture instructions for each repository. The selected repository opens a panel with four tabs: - **Chat** — converse with the indexed repository; - **Improve** — generate categorized improvement suggestions; diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index 945363188..e5674d91a 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -22,6 +22,8 @@ The backend authenticates to GitHub in one of three modes — `demo`, `relay`, o | `HOST_GH_PRIVATE_KEY` | Unset | Absolute host path to the `.pem`. The CLI/launcher bind-mounts it read-only into the app containers and overrides `GH_PRIVATE_KEY_PATH`, so the key can live anywhere on the host. No `~`. | App mode via the `propr` CLI or launcher. | | `GH_OAUTH_CLIENT_ID` / `GH_OAUTH_CLIENT_SECRET` | Placeholders | GitHub OAuth App credentials for Web UI login. | Always, for UI login. | | `GH_OAUTH_CALLBACK_URL` | Derived: `/api/auth/github/callback` | OAuth callback served by the API. Leave commented so tunnel-mode derivation wins; an active localhost value is used as-is even in tunnel mode. Register the URL — derived or explicit — in your GitHub OAuth App. | Override only. | +| `GITHUB_VISUAL_PREVIEW_TOKEN` | Unset | Advanced override for the OAuth App token (`gho_`), classic PAT, or fine-grained PAT used only to upload visual-preview attachments. Administrators can normally paste a PAT in Settings instead, and `propr setup` imports a compatible `gh` CLI token when available. GitHub's uploader rejects GitHub App user (`ghu_`) and installation (`ghs_`) tokens. | Optional override. | +| `PROPR_CREDENTIAL_ENCRYPTION_KEY` | `SYSTEM_TASK_SECRET`, then `SESSION_SECRET` | Optional dedicated secret used to encrypt the persisted visual-preview OAuth grant. It must be identical in the API and worker containers and remain stable across restarts; changing it requires reconnecting the GitHub login. | Optional security isolation. | | `SESSION_SECRET` | Placeholder | Signs browser session cookies. | Always. | | `ENABLE_BEARER_AUTH` | `true` (any value except `false` enables it) | Bearer token auth for the CLI. Set `false` to allow session login only. | Optional. | | `PROPR_DEMO_MODE` | `false` | `true`/`1` allows read-only access without GitHub OAuth and blocks all mutating API requests. Use a curated config/database for public demos. | Demo deployments. | @@ -44,7 +46,7 @@ The backend authenticates to GitHub in one of three modes — `demo`, `relay`, o | `WEB_PUSH_RETRY_BASE_MS` / `WEB_PUSH_RETRY_CAP_MS` | `30000` / `900000` | Base and cap for exponential retry scheduling after throttling, provider errors, or network failures. | Optional Web Push tuning. | | `PROPR_ALLOW_INSECURE_LOCAL_WEB_PUSH` | `false` | Requests loopback HTTP Push enrollment for isolated local development. It is honored only outside production when `API_PUBLIC_URL` is unset/local or has a loopback host, and can be changed without migrating the stable schema. | Local browser development only. | | `PROPR_API_RATE_LIMIT_MAX` / `PROPR_API_RATE_LIMIT_WINDOW_MS` | `600` / `60000` | Per-client quota and window (milliseconds) for all `/api` requests. | Optional tuning. | -| `PROPR_AUTH_RATE_LIMIT_MAX` / `PROPR_AUTH_RATE_LIMIT_WINDOW_MS` | `30` / `900000` | Additional, tighter per-client quota for OAuth and session endpoints. | Optional tuning. | +| `PROPR_AUTH_RATE_LIMIT_MAX` / `PROPR_AUTH_RATE_LIMIT_WINDOW_MS` | `30` / `900000` | Additional, tighter per-client quota for OAuth initiation and callback endpoints. | Optional tuning. | | `PROPR_WEBHOOK_RATE_LIMIT_MAX` / `PROPR_WEBHOOK_RATE_LIMIT_WINDOW_MS` | `300` / `60000` | Per-client quota for direct webhook requests, applied before body parsing and signature verification. | Optional tuning in direct-webhook mode. | | `PROPR_TRUSTED_PROXY_PEERS` | Unset; launcher-managed tunnel: reserved `self` mode | Comma-separated immediate proxy IPs, CIDRs, or `proxy-addr` names whose forwarded client IP and protocol are trusted. Unset ignores forwarding headers. The launcher injects `self` only for its managed sidecar sharing the API network namespace. Its broad `uniquelocal` name is accepted only when `API_PORT` is explicitly loopback-bound. | Reverse-proxy deployments; injected automatically for the managed tunnel. | | `LOG_LEVEL` | `info` | Log verbosity across services. | Optional. | diff --git a/docs/sidebars.ts b/docs/sidebars.ts index b8bde75e4..d379737bf 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -67,6 +67,7 @@ const sidebars: SidebarsConfig = { items: [ 'features/pr-followup', 'features/pr-commands', + 'features/visual-previews', ], }, { diff --git a/package.json b/package.json index 77f220b14..367bb996d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:notifications:server": "node scripts/run-test-suite.mjs test/notificationSchema.test.ts test/notificationPreferenceMigration.test.ts packages/core/test/notificationService.test.ts packages/core/test/planNotificationActionsMigration.test.ts packages/core/test/pushSubscriptionExpiration.test.ts packages/api/test/notificationRoutes.test.ts packages/api/test/notificationManagementRoutes.test.ts packages/api/test/notificationProjectionService.test.ts packages/api/test/webPushDispatcher.test.ts", "test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx", "test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui", - "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", + "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", "test:e2e": "npx tsx --test test/e2e.test.ts", "test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts", "test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test", diff --git a/packages/api/auth.ts b/packages/api/auth.ts index 8cc796ec7..420a52e4c 100644 --- a/packages/api/auth.ts +++ b/packages/api/auth.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- browser, bearer, and socket authentication share session state */ import passport from 'passport'; import { Strategy as GitHubStrategy, Profile } from 'passport-github2'; import session from 'express-session'; @@ -8,7 +9,7 @@ import type { Express, Request, Response, NextFunction, RequestHandler } from 'e import { validateSessionSecret } from '@propr/shared'; import { validateGitHubToken } from './authBearer.js'; import { configureDemoMode, getDemoUser, isDemoMode } from './demoMode.js'; -import { clearSessionForReauth, isGitHubTokenExpired, refreshGitHubTokenIfNeeded, refreshGitHubTokenWithResult } from './authGithubTokens.js'; +import { clearSessionForReauth, isGitHubTokenExpired, refreshGitHubTokenWithResult } from './authGithubTokens.js'; import { getValidatedRedirectTo, getDefaultRedirectUrl } from './authRedirect.js'; import { isUserWhitelisted } from './userWhitelist.js'; import type { GitHubUser } from './authTypes.js'; @@ -32,6 +33,7 @@ import { resolveInstanceAuthorization, type InstanceAuthorization, } from './authorization.js'; +import { captureVisualPreviewCredentialFromAdminLogin } from './services/visualPreviewOAuth.js'; import './authTypes.js'; export { refreshGitHubTokenIfNeeded } from './authGithubTokens.js'; @@ -87,7 +89,7 @@ export function createGitHubOAuthStrategy(config: GitHubOAuthStrategyConfig): Gi state: true as unknown as string, }, // eslint-disable-next-line max-params - function verifyCallback(accessToken: string, refreshToken: string, params: { expires_in?: number }, profile: Profile, done: (error: Error | null, user?: GitHubUser) => void) { + function verifyCallback(accessToken: string, refreshToken: string, params: { expires_in?: number; refresh_token_expires_in?: number }, profile: Profile, done: (error: Error | null, user?: GitHubUser) => void) { console.log('User authenticated:', profile.username); const tokenExpiresAt = params.expires_in ? Date.now() + (params.expires_in * 1000) : undefined; @@ -101,6 +103,10 @@ export function createGitHubOAuthStrategy(config: GitHubOAuthStrategyConfig): Gi accessToken, refreshToken: refreshToken || undefined, tokenExpiresAt, + refreshTokenExpiresAt: params.refresh_token_expires_in + ? Date.now() + (params.refresh_token_expires_in * 1000) + : undefined, + oauthSource: 'github', }; return done(null, user); }); @@ -136,7 +142,7 @@ export function createConnectCallbackHandler( redirectAuthError(res, 'session_unavailable'); return; } - completeAuthenticatedSession(req, res); + void completeAuthenticatedSessionWithPreviewCredential(req, res); }); } catch (error) { console.error('Connect instance login failed:', error); @@ -145,6 +151,20 @@ export function createConnectCallbackHandler( }; } +async function completeAuthenticatedSessionWithPreviewCredential(req: Request, res: Response): Promise { + if (req.user && isUserWhitelisted(req.user.username)) { + try { + const captured = await captureVisualPreviewCredentialFromAdminLogin(req.user); + if (captured) console.log(`[visual-preview] Captured OAuth upload credential for administrator ${req.user.username}`); + } catch (error) { + // Preview uploads are optional; a storage or encryption issue must not + // prevent an otherwise valid administrator from logging in. + console.warn('[visual-preview] Could not capture OAuth upload credential during login:', (error as Error).message); + } + } + completeAuthenticatedSession(req, res); +} + export function setupAuth(app: Express, demoModeAtStartup = isDemoMode()): SocketAuthMiddlewareBundle { configureDemoMode(demoModeAtStartup); const browserAuthMode = demoModeAtStartup ? 'disabled' : resolveBrowserAuthMode(); @@ -165,9 +185,11 @@ export function setupAuth(app: Express, demoModeAtStartup = isDemoMode()): Socke } const engineMiddleware: RequestHandler[] = []; - // Keep unauthenticated OAuth/session endpoints bounded independently from - // the general API quota. Register this before session and Passport work. - app.use('/api/auth', createAuthRequestRateLimiter()); + // Keep OAuth starts and callbacks bounded independently from the general + // API quota. Session checks, logout, and auth metadata remain covered by + // the general API limiter and must not exhaust the much smaller OAuth + // bucket during normal UI use. + app.use('/api/auth/github', createAuthRequestRateLimiter()); if (!demoModeAtStartup) { // Create Redis client for session store @@ -274,7 +296,7 @@ export function setupAuth(app: Express, demoModeAtStartup = isDemoMode()): Socke } else if (browserAuthMode === 'github') { app.get('/api/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), - completeAuthenticatedSession + completeAuthenticatedSessionWithPreviewCredential ); } else if (browserAuthMode === 'connect') { app.get('/api/auth/github/callback', createConnectCallbackHandler()); @@ -380,6 +402,8 @@ export async function authenticateSocketRequest( throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'Authentication required'); } +// Session, bearer, demo, and refresh outcomes are intentionally centralized. +// eslint-disable-next-line complexity export async function ensureAuthenticated(req: Request, res: Response, next: NextFunction): Promise { if (isDemoMode()) { res.set('X-ProPR-Demo-Mode', 'true'); @@ -419,10 +443,19 @@ export async function ensureAuthenticated(req: Request, res: Response, next: Nex return; } } else { - // Proactively refresh token in background if needed. - refreshGitHubTokenIfNeeded(req).catch((err) => { - console.error('Background token refresh failed:', err); - }); + // Await synchronization with the durable upload grant before a + // downstream route can use an access token invalidated by rotation. + // Temporary proactive-refresh failures do not invalidate a token + // whose recorded expiry is still in the future. + const refreshResult = await refreshGitHubTokenWithResult(req); + if (req.user?.githubAuthInvalid) { + if (req.user?.githubAuthInvalid) await clearSessionForReauth(req); + res.status(401).json({ error: 'GitHub authentication expired', code: 'GITHUB_REAUTH_REQUIRED', message: 'Your GitHub session has expired. Please log in again.' }); + return; + } + if (refreshResult.status === 'temporarily-unavailable') { + console.warn('Proactive GitHub token refresh was temporarily unavailable; continuing with the unexpired session token'); + } } return next(); } diff --git a/packages/api/authGithubTokens.ts b/packages/api/authGithubTokens.ts index 8b6063d2e..238e36cb3 100644 --- a/packages/api/authGithubTokens.ts +++ b/packages/api/authGithubTokens.ts @@ -1,11 +1,18 @@ import type { Request } from 'express'; +import { isSupportedVisualPreviewUploadToken } from '@propr/core'; +import { + updateVisualPreviewCredentialForCurrentOwner, + visualPreviewOAuthCredentialService, +} from './services/visualPreviewOAuth.js'; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const TOKEN_REFRESH_TIMEOUT_MS = 20_000; interface GitHubTokenRefreshResponse { access_token?: string; refresh_token?: string; expires_in?: number; + refresh_token_expires_in?: number; error?: string; error_description?: string; } @@ -17,6 +24,7 @@ export interface GitHubTokenRefreshResult { accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; + refreshTokenExpiresAt?: number; } const sessionRefreshes = new Map>(); @@ -38,6 +46,7 @@ async function markGitHubSessionReauthRequired(req: Request, reason: string): Pr user.accessToken = ''; delete user.refreshToken; delete user.tokenExpiresAt; + delete user.refreshTokenExpiresAt; await new Promise(resolve => { req.session.save(err => { @@ -72,6 +81,7 @@ function applyRefreshResultToRequest(req: Request, result: GitHubTokenRefreshRes user.accessToken = result.accessToken; if (result.refreshToken) user.refreshToken = result.refreshToken; if (result.tokenExpiresAt) user.tokenExpiresAt = result.tokenExpiresAt; + if (result.refreshTokenExpiresAt) user.refreshTokenExpiresAt = result.refreshTokenExpiresAt; } async function saveSession(req: Request, successMessage: string): Promise { @@ -88,10 +98,85 @@ async function saveSession(req: Request, successMessage: string): Promise }); } +function buildTokenRefreshRequest(user: NonNullable): { endpoint: string; init: RequestInit } { + if (user.oauthSource === 'connect') { + const relayUrl = process.env.PROPR_GH_RELAY_URL?.trim().replace(/\/+$/, ''); + const relayToken = process.env.PROPR_GH_RELAY_TOKEN?.trim(); + if (!relayUrl || !relayToken) throw new Error('ProPR Connect credentials are unavailable for token refresh'); + const endpoint = new URL(`${relayUrl}/auth/instance-grants/refresh`); + if (endpoint.protocol !== 'https:' && endpoint.hostname !== 'localhost' && endpoint.hostname !== '127.0.0.1') { + throw new Error('PROPR_GH_RELAY_URL must use HTTPS'); + } + return { + endpoint: endpoint.toString(), + init: { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${relayToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: user.refreshToken }), + }, + }; + } + return { + endpoint: 'https://github.com/login/oauth/access_token', + init: { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: process.env.GH_OAUTH_CLIENT_ID, + client_secret: process.env.GH_OAUTH_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: user.refreshToken, + }), + }, + }; +} + +// Session fallback and the shared background credential deliberately converge here. +// eslint-disable-next-line complexity async function performGitHubTokenRefresh(req: Request, force: boolean): Promise { const user = req.user; if (!user || user.githubAuthInvalid) return { status: 'reauth-required' }; - if (!user.refreshToken) return { status: 'reauth-required' }; + const supportsVisualPreviewUploads = isSupportedVisualPreviewUploadToken(user.accessToken || ''); + + if (supportsVisualPreviewUploads) { + try { + const sharedGrant = await visualPreviewOAuthCredentialService.refreshAndGetForOwner(user.id, force); + if (sharedGrant?.status === 'reauth_required') { + await markGitHubSessionReauthRequired(req, 'shared_visual_preview_grant_invalid'); + return { status: 'reauth-required' }; + } + if (sharedGrant?.accessToken) { + const changed = user.accessToken !== sharedGrant.accessToken + || user.refreshToken !== sharedGrant.refreshToken + || user.tokenExpiresAt !== sharedGrant.accessTokenExpiresAt; + user.accessToken = sharedGrant.accessToken; + user.refreshToken = sharedGrant.refreshToken; + user.tokenExpiresAt = sharedGrant.accessTokenExpiresAt; + user.refreshTokenExpiresAt = sharedGrant.refreshTokenExpiresAt; + if (changed) { + await saveSession(req, `Synchronized refreshed GitHub token for user ${user.username}`); + } + return { + status: changed ? 'refreshed' : 'not-needed', + accessToken: user.accessToken, + refreshToken: user.refreshToken, + tokenExpiresAt: user.tokenExpiresAt, + refreshTokenExpiresAt: user.refreshTokenExpiresAt, + }; + } + } catch (error) { + console.error('Error refreshing shared GitHub OAuth credential:', error); + return { status: 'temporarily-unavailable' }; + } + } + + if (!user.refreshToken) { + return { status: force ? 'reauth-required' : 'not-needed' }; + } const now = Date.now(); const needsRefresh = force || (user.tokenExpiresAt && (user.tokenExpiresAt - now) < TOKEN_REFRESH_BUFFER_MS); @@ -100,15 +185,10 @@ async function performGitHubTokenRefresh(req: Request, force: boolean): Promise< console.log(`Refreshing GitHub token for user ${user.username} (force=${force})`); try { - const response = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: process.env.GH_OAUTH_CLIENT_ID, - client_secret: process.env.GH_OAUTH_CLIENT_SECRET, - grant_type: 'refresh_token', - refresh_token: user.refreshToken, - }), + const refreshRequest = buildTokenRefreshRequest(user); + const response = await fetch(refreshRequest.endpoint, { + ...refreshRequest.init, + signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), }); if (!response.ok) { console.error(`GitHub token refresh failed with status ${response.status}`); @@ -129,14 +209,25 @@ async function performGitHubTokenRefresh(req: Request, force: boolean): Promise< user.accessToken = data.access_token; if (data.refresh_token) user.refreshToken = data.refresh_token; if (data.expires_in) user.tokenExpiresAt = Date.now() + (data.expires_in * 1000); + if (data.refresh_token_expires_in) { + user.refreshTokenExpiresAt = Date.now() + (data.refresh_token_expires_in * 1000); + } await saveSession(req, `Successfully refreshed GitHub token for user ${user.username}`); + if (supportsVisualPreviewUploads) { + try { + await updateVisualPreviewCredentialForCurrentOwner(user); + } catch (error) { + console.warn('[visual-preview] Could not persist the refreshed OAuth upload credential:', (error as Error).message); + } + } return { status: 'refreshed', accessToken: user.accessToken, refreshToken: user.refreshToken, tokenExpiresAt: user.tokenExpiresAt, + refreshTokenExpiresAt: user.refreshTokenExpiresAt, }; } catch (error) { console.error('Error refreshing GitHub token:', error); diff --git a/packages/api/authTypes.ts b/packages/api/authTypes.ts index afbbdaf57..fc895b9b9 100644 --- a/packages/api/authTypes.ts +++ b/packages/api/authTypes.ts @@ -8,6 +8,8 @@ export interface GitHubUser { accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; + refreshTokenExpiresAt?: number; + oauthSource?: 'github' | 'connect'; githubAuthInvalid?: boolean; } diff --git a/packages/api/connectAuth.ts b/packages/api/connectAuth.ts index f810c61ee..720e2d837 100644 --- a/packages/api/connectAuth.ts +++ b/packages/api/connectAuth.ts @@ -103,6 +103,12 @@ export async function redeemConnectAuthorizationCode(options: { email: null, avatarUrl: body.avatar_url, accessToken: body.access_token, + refreshToken: body.refresh_token, + tokenExpiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined, + refreshTokenExpiresAt: body.refresh_token_expires_in + ? Date.now() + body.refresh_token_expires_in * 1000 + : undefined, + oauthSource: 'connect', }; } @@ -169,6 +175,9 @@ function isRedeemedIdentity(value: unknown): value is { username: string; avatar_url: string | null; access_token: string; + refresh_token?: string; + expires_in?: number; + refresh_token_expires_in?: number; } { if (typeof value !== 'object' || value === null) return false; const candidate = value as Record; @@ -176,6 +185,10 @@ function isRedeemedIdentity(value: unknown): value is { isGitHubLogin(candidate.username) && (candidate.avatar_url === null || typeof candidate.avatar_url === 'string') && typeof candidate.access_token === 'string' && - candidate.access_token.length > 0 + candidate.access_token.length > 0 && + (candidate.refresh_token === undefined || typeof candidate.refresh_token === 'string') && + (candidate.expires_in === undefined || (typeof candidate.expires_in === 'number' && candidate.expires_in > 0)) && + (candidate.refresh_token_expires_in === undefined + || (typeof candidate.refresh_token_expires_in === 'number' && candidate.refresh_token_expires_in > 0)) ); } diff --git a/packages/api/routeRegistry.ts b/packages/api/routeRegistry.ts index a9c52b05b..89986b7c9 100644 --- a/packages/api/routeRegistry.ts +++ b/packages/api/routeRegistry.ts @@ -6,6 +6,7 @@ import type { createAgentVersionRoutes, createConfigRoutes, createInstanceCatalogRoutes, + createVisualPreviewAuthRoutes, } from './routes/index.js'; import { requireAgentTankUsageAccess, @@ -26,6 +27,7 @@ interface ManagementRouteDeps { agentRuntimeRoutes: ReturnType; agentVersionRoutes: ReturnType; configRoutes: ReturnType; + visualPreviewAuthRoutes: ReturnType; } interface MemberCatalogRouteDeps { @@ -38,6 +40,7 @@ export function createManagementRouteEntries({ agentRuntimeRoutes, agentVersionRoutes, configRoutes, + visualPreviewAuthRoutes, }: ManagementRouteDeps): RouteEntry[] { return [ ['get', '/api/config/followup-keywords', requireManageSettings, configRoutes.getFollowupKeywords], @@ -70,6 +73,10 @@ export function createManagementRouteEntries({ ['get', '/api/config/agent-tank/usage', requireAgentTankUsageAccess, configRoutes.getAgentTankUsage], ['post', '/api/config/agent-tank/refresh', requireManageAgents, configRoutes.postAgentTankRefresh], ['get', '/api/config/agent-tank/detect', requireManageAgents, configRoutes.getAgentTankDetect], + ['get', '/api/config/visual-preview-auth', requireManageSettings, visualPreviewAuthRoutes.getStatus], + ['post', '/api/config/visual-preview-auth', requireManageSettings, visualPreviewAuthRoutes.useCurrentLogin], + ['put', '/api/config/visual-preview-auth/token', requireManageSettings, visualPreviewAuthRoutes.usePersonalAccessToken], + ['delete', '/api/config/visual-preview-auth', requireManageSettings, visualPreviewAuthRoutes.disconnect], ['get', '/api/admin/members', requireManageMembers, adminRoutes.listMembers], ['get', '/api/admin/role-audit', requireManageMembers, adminRoutes.listRoleAudit], diff --git a/packages/api/routes/configRepoValidation.ts b/packages/api/routes/configRepoValidation.ts index bb19561c2..7f68dd9e6 100644 --- a/packages/api/routes/configRepoValidation.ts +++ b/packages/api/routes/configRepoValidation.ts @@ -1,7 +1,30 @@ import { randomUUID } from 'crypto'; -import type { RepoToMonitor } from '@propr/core'; +import type { RepoToMonitor, VisualPreviewSettings, VisualPreviewType } from '@propr/core'; import { normalizeOptionalBranchName } from './branchNameValidation.js'; +const MAX_VISUAL_PREVIEW_INSTRUCTIONS_LENGTH = 4000; + +// Keep API input normalization side-effect-free. Importing the core package at +// runtime initializes GitHub authentication, while this validator is also used +// by standalone tooling and unit tests. +function normalizeStoredVisualPreviewSettings(value: unknown): VisualPreviewSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { enabled: false, types: ['image'] }; + } + const candidate = value as Partial; + const types = Array.isArray(candidate.types) + ? [...new Set(candidate.types.filter((type): type is VisualPreviewType => type === 'image' || type === 'video'))] + : []; + const instructions = typeof candidate.instructions === 'string' && candidate.instructions.trim() + ? candidate.instructions.trim() + : undefined; + return { + enabled: candidate.enabled === true, + types: types.length > 0 ? types : ['image'], + ...(instructions ? { instructions } : {}) + }; +} + type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; function success(value: T): ValidationResult { @@ -45,6 +68,13 @@ export function withDefaultRepoAutoFollowup(repo: RepoToMonitor): RepoToMonitor return { ...repo, autoFollowupOnFailedCi: repo.autoFollowupOnFailedCi === true }; } +export function withDefaultRepoOptions(repo: RepoToMonitor): RepoToMonitor { + return { + ...withDefaultRepoAutoFollowup(repo), + visualPreview: normalizeStoredVisualPreviewSettings(repo.visualPreview) + }; +} + export function preserveRepoAutoFollowup( previousRepos: RepoToMonitor[], normalizedRepos: RepoToMonitor[], @@ -58,6 +88,87 @@ export function preserveRepoAutoFollowup( }); } +export function preserveRepoVisualPreview( + previousRepos: RepoToMonitor[], + normalizedRepos: RepoToMonitor[], + incomingRepos: unknown[] +): RepoToMonitor[] { + const explicitByRepository = new Map(); + const changedByRepository = new Map(); + normalizedRepos.forEach((repo, index) => { + const incoming = incomingRepos[index] as Partial; + if (incoming.visualPreview !== undefined) { + const repositoryKey = repo.name.trim().toLowerCase(); + const normalized = normalizeStoredVisualPreviewSettings(repo.visualPreview); + if (!explicitByRepository.has(repositoryKey)) explicitByRepository.set(repositoryKey, normalized); + const previous = previousRepos.find(candidate => candidate.id === repo.id); + if (JSON.stringify(normalized) !== JSON.stringify(normalizeStoredVisualPreviewSettings(previous?.visualPreview))) { + changedByRepository.set(repositoryKey, normalized); + } + } + }); + + return normalizedRepos.map(repo => { + const repositoryKey = repo.name.trim().toLowerCase(); + const changed = changedByRepository.get(repositoryKey); + if (changed) return { ...repo, visualPreview: changed }; + + const previousMatches = previousRepos.filter( + candidate => candidate.name.trim().toLowerCase() === repositoryKey + ); + if (previousMatches.length > 0) { + const configured = previousMatches.find( + candidate => normalizeStoredVisualPreviewSettings(candidate.visualPreview).enabled + ) ?? previousMatches.find(candidate => candidate.visualPreview !== undefined); + return { ...repo, visualPreview: normalizeStoredVisualPreviewSettings(configured?.visualPreview) }; + } + + const explicit = explicitByRepository.get(repositoryKey); + return { ...repo, visualPreview: explicit ?? normalizeStoredVisualPreviewSettings(repo.visualPreview) }; + }); +} + +function normalizeVisualPreviewTypes(value: unknown, repoName: string): ValidationResult { + if (value === undefined) return success(['image']); + if (!Array.isArray(value)) { + return failure(`Invalid visualPreview.types format for ${repoName}: must be an array`); + } + if (value.some(type => type !== 'image' && type !== 'video')) { + return failure(`Invalid visualPreview.types format for ${repoName}: supported values are image and video`); + } + return success([...new Set(value as VisualPreviewType[])]); +} + +function normalizeVisualPreview(value: unknown, repoName: string): ValidationResult { + if (value === undefined) return success({ enabled: false, types: ['image'] }); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return failure(`Invalid visualPreview format for ${repoName}: must be an object`); + } + + const candidate = value as Partial; + if (typeof candidate.enabled !== 'boolean') { + return failure(`Invalid visualPreview.enabled format for ${repoName}: must be a boolean`); + } + const types = normalizeVisualPreviewTypes(candidate.types, repoName); + if (!types.ok) return types; + if (candidate.enabled && types.value.length === 0) { + return failure(`Invalid visualPreview.types format for ${repoName}: select at least one type when previews are enabled`); + } + if (candidate.instructions !== undefined && typeof candidate.instructions !== 'string') { + return failure(`Invalid visualPreview.instructions format for ${repoName}: must be a string`); + } + const instructions = candidate.instructions?.trim(); + if (instructions && instructions.length > MAX_VISUAL_PREVIEW_INSTRUCTIONS_LENGTH) { + return failure(`Invalid visualPreview.instructions format for ${repoName}: must be ${MAX_VISUAL_PREVIEW_INSTRUCTIONS_LENGTH} characters or fewer`); + } + + return success({ + enabled: candidate.enabled, + types: types.value.length > 0 ? types.value : ['image'], + ...(instructions ? { instructions } : {}) + }); +} + export function normalizeRepoConfig(repo: unknown): ValidationResult { const candidateResult = parseRepoObject(repo); if (!candidateResult.ok) return candidateResult; @@ -78,12 +189,15 @@ export function normalizeRepoConfig(repo: unknown): ValidationResult body.followup_ignore_keywords, validate: followup_ignore_keywords => parseNormalizedStringArrayResult(followup_ignore_keywords, 'followup_ignore_keywords'), save: followup_ignore_keywords => configStore.saveFollowupIgnoreKeywords(followup_ignore_keywords), subtype: 'followup_ignore_keywords_update', body: followup_ignore_keywords => ({ followup_ignore_keywords }), committedErrorMessage: 'Follow-up ignore keywords were saved, but publishing the config update notification failed. Persisted config may require a follow-up check.' }); const getRepos = createJsonGetHandler( - async () => (await configStore.loadMonitoredReposRaw()).map(withDefaultRepoAutoFollowup), + async () => (await configStore.loadMonitoredReposRaw()).map(withDefaultRepoOptions), repos_to_monitor => ({ repos_to_monitor }), 'Failed to load repository configuration', '/api/config/repos GET' @@ -214,7 +214,8 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { } const result = await withConfigLock(redisClient, 'config:repos:lock', async lock => { const previousRepos = await configStore.loadMonitoredReposRaw(); - const processedRepos = preserveRepoAutoFollowup(previousRepos, validatedRepos, repos_to_monitor); + const withPreservedAutoFollowup = preserveRepoAutoFollowup(previousRepos, validatedRepos, repos_to_monitor); + const processedRepos = preserveRepoVisualPreview(previousRepos, withPreservedAutoFollowup, repos_to_monitor); return saveThenPublishConfigUpdate({ save: async () => { await database.transaction(async trx => { diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index 8c018e944..3d0ddfbcf 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -29,3 +29,4 @@ export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js' export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createNotificationRoutes } from './notificationRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { createVisualPreviewAuthRoutes } from './visualPreviewAuthRoutes.js'; diff --git a/packages/api/routes/visualPreviewAuthRoutes.ts b/packages/api/routes/visualPreviewAuthRoutes.ts new file mode 100644 index 000000000..8b0c47bdb --- /dev/null +++ b/packages/api/routes/visualPreviewAuthRoutes.ts @@ -0,0 +1,172 @@ +import type { Request, Response } from 'express'; +import { + VisualPreviewOAuthCredentialService, + isSupportedVisualPreviewUploadToken, + type VisualPreviewOAuthCredentialStatus, +} from '@propr/core'; +import { + visualPreviewCredentialFromUser, + visualPreviewOAuthCredentialService, +} from '../services/visualPreviewOAuth.js'; + +const GITHUB_USER_URL = 'https://api.github.com/user'; +const GITHUB_REQUEST_TIMEOUT_MS = 20_000; +const MAX_TOKEN_LENGTH = 512; + +interface VisualPreviewAuthRoutesDeps { + service?: VisualPreviewOAuthCredentialService; + fetchImpl?: typeof fetch; +} + +type CurrentLoginTokenType = 'supported' | 'github_app_user' | 'unsupported' | 'missing'; + +function currentLoginTokenType(accessToken?: string): CurrentLoginTokenType { + const token = accessToken?.trim(); + if (!token) return 'missing'; + if (isSupportedVisualPreviewUploadToken(token)) return 'supported'; + if (token.startsWith('ghu_')) return 'github_app_user'; + return 'unsupported'; +} + +function statusResponse(req: Request, status: VisualPreviewOAuthCredentialStatus) { + const loginTokenType = currentLoginTokenType(req.user?.accessToken); + return { + ...status, + currentUsername: req.user?.username, + currentLoginTokenType: loginTokenType, + canUseCurrentLogin: loginTokenType === 'supported', + }; +} + +function sendFailure(error: unknown, res: Response): void { + console.error('[visual-preview] Credential administration failed:', error); + res.status(500).json({ + error: 'Visual-preview upload credential administration failed', + code: 'VISUAL_PREVIEW_AUTH_ADMINISTRATION_FAILED', + }); +} + +function readSubmittedToken(req: Request): string | null { + const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (!token || token.length > MAX_TOKEN_LENGTH || !isSupportedVisualPreviewUploadToken(token)) return null; + return token; +} + +async function fetchGitHubIdentity(token: string, fetchImpl: typeof fetch): Promise<{ + status: number; + id?: string; + username?: string; +}> { + const response = await fetchImpl(GITHUB_USER_URL, { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'user-agent': 'ProPR', + 'x-github-api-version': '2022-11-28', + }, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return { status: response.status }; + const identity = await response.json() as { id?: number; login?: string }; + if (!Number.isSafeInteger(identity.id) || !identity.login) return { status: 502 }; + return { status: 200, id: String(identity.id), username: identity.login }; +} + +export function createVisualPreviewAuthRoutes({ + service = visualPreviewOAuthCredentialService, + fetchImpl = fetch, +}: VisualPreviewAuthRoutesDeps = {}) { + async function getStatus(req: Request, res: Response): Promise { + try { + res.json(statusResponse(req, await service.getStatus())); + } catch (error) { + sendFailure(error, res); + } + } + + async function useCurrentLogin(req: Request, res: Response): Promise { + const credential = req.user ? visualPreviewCredentialFromUser(req.user) : null; + if (!credential) { + const githubAppUserToken = currentLoginTokenType(req.user?.accessToken) === 'github_app_user'; + res.status(409).json({ + error: githubAppUserToken + ? 'The current login uses a GitHub App user token, which GitHub attachment uploads reject. Add a personal access token in Visual preview uploads instead.' + : 'The current GitHub login did not provide an OAuth App token or personal access token supported by GitHub attachment uploads.', + code: 'VISUAL_PREVIEW_LOGIN_TOKEN_UNSUPPORTED', + }); + return; + } + try { + await service.replace(credential); + res.json(statusResponse(req, await service.getStatus())); + } catch (error) { + sendFailure(error, res); + } + } + + async function usePersonalAccessToken(req: Request, res: Response): Promise { + const token = readSubmittedToken(req); + if (!token) { + res.status(400).json({ + error: 'Enter a GitHub OAuth App token or personal access token (gho_, ghp_, or github_pat_). GitHub App tokens are not supported for attachment uploads.', + code: 'VISUAL_PREVIEW_TOKEN_UNSUPPORTED', + }); + return; + } + + try { + const currentStatus = await service.getStatus(); + if (currentStatus.source === 'environment') { + res.status(409).json({ + error: 'GITHUB_VISUAL_PREVIEW_TOKEN manages this credential. Remove the environment override and restart the stack before saving a token in Settings.', + code: 'VISUAL_PREVIEW_TOKEN_ENVIRONMENT_MANAGED', + }); + return; + } + + const identity = await fetchGitHubIdentity(token, fetchImpl); + if (identity.status === 401 || identity.status === 403) { + res.status(400).json({ + error: 'GitHub rejected this token. Check that it is active and has access to the repositories where previews are uploaded.', + code: 'VISUAL_PREVIEW_TOKEN_INVALID', + }); + return; + } + if (identity.status !== 200 || !identity.id || !identity.username) { + res.status(502).json({ + error: 'GitHub could not validate this token. Please try again.', + code: 'VISUAL_PREVIEW_TOKEN_VALIDATION_FAILED', + }); + return; + } + + await service.replace({ + githubUserId: identity.id, + githubUsername: identity.username, + source: 'static_token', + accessToken: token, + }); + res.json(statusResponse(req, await service.getStatus())); + } catch (error) { + if (error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError')) { + res.status(502).json({ + error: 'GitHub could not validate this token. Please try again.', + code: 'VISUAL_PREVIEW_TOKEN_VALIDATION_FAILED', + }); + return; + } + sendFailure(error, res); + } + } + + async function disconnect(_req: Request, res: Response): Promise { + try { + await service.disconnect(); + res.status(204).end(); + } catch (error) { + sendFailure(error, res); + } + } + + return { getStatus, useCurrentLogin, usePersonalAccessToken, disconnect }; +} diff --git a/packages/api/server.ts b/packages/api/server.ts index 14a958ba0..5840f9df1 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -31,6 +31,7 @@ import { createUserRepoPreferencesRoutes, createAgentRuntimeRoutes, createNotificationRoutes, createAdminRoutes, + createVisualPreviewAuthRoutes, createInstanceCatalogRoutes, attachmentUpload } from './routes/index.js'; @@ -72,6 +73,10 @@ import { type RouteEntry } from './routeRegistry.js'; import { createTaskDeleteRouteEntries } from './taskDeleteRouteRegistry.js'; +import { + startVisualPreviewOAuthRefreshScheduler, + type VisualPreviewOAuthRefreshScheduler, +} from './services/visualPreviewOAuth.js'; type ShutdownTask = { name: string; close: () => Promise }; @@ -190,6 +195,7 @@ let configReloadSubscription: ConfigReloadSubscription | undefined; let notificationProjection: NotificationProjectionService | undefined; let webPushDispatcher: WebPushDispatcher | undefined; let webPushDispatcherConfigured = false; +let visualPreviewOAuthRefreshScheduler: VisualPreviewOAuthRefreshScheduler | undefined; function createDemoTaskQueue(): Queue { return { @@ -275,6 +281,7 @@ function setupRoutes(): void { const agentRuntimeRoutes = createAgentRuntimeRoutes({ getRuntimeBuildQueue: () => runtimeBuildQueue }); const notificationRoutes = createNotificationRoutes({ webPushDispatcherConfigured }); const adminRoutes = createAdminRoutes(); + const visualPreviewAuthRoutes = createVisualPreviewAuthRoutes(); const instanceCatalogRoutes = createInstanceCatalogRoutes(); const agentVersionRoutes = createAgentVersionRoutes(); @@ -311,6 +318,7 @@ function setupRoutes(): void { agentRuntimeRoutes, agentVersionRoutes, configRoutes, + visualPreviewAuthRoutes, }), ]; assertNoDuplicateRoutes(routes); @@ -425,6 +433,7 @@ async function start(): Promise { // chain so no settings update can race with the startup snapshot. await configReloadSubscription.reload(); await initializePushSubscriptionMaintenance(); + visualPreviewOAuthRefreshScheduler = await startVisualPreviewOAuthRefreshScheduler(); try { const removed = await agentLoginSessionManager.cleanupOrphanedContainers(); if (removed > 0) console.log(`Removed ${removed} orphaned agent login container(s)`); @@ -488,6 +497,7 @@ async function start(): Promise { if (!demoMode) { shutdownTasks.push( { name: 'Web Push dispatcher', close: () => webPushDispatcher?.close() ?? Promise.resolve() }, + { name: 'visual-preview OAuth refresh scheduler', close: () => visualPreviewOAuthRefreshScheduler?.close() ?? Promise.resolve() }, { name: 'config reload subscriber', close: () => configReloadSubscription?.close() ?? Promise.resolve() }, { name: 'ultrafix state redis', close: () => closeUltrafixStateRedis() }, { name: 'socket service', close: () => closeSocketService() }, diff --git a/packages/api/services/visualPreviewOAuth.ts b/packages/api/services/visualPreviewOAuth.ts new file mode 100644 index 000000000..814c8a5e1 --- /dev/null +++ b/packages/api/services/visualPreviewOAuth.ts @@ -0,0 +1,76 @@ +import { + VisualPreviewOAuthCredentialService, + isSupportedVisualPreviewUploadToken, + type VisualPreviewOAuthCredentialInput, +} from '@propr/core'; +import type { GitHubUser } from '../authTypes.js'; +import { resolveInstanceAuthorization } from '../authorization.js'; +import { isUserWhitelisted } from '../userWhitelist.js'; + +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +export const visualPreviewOAuthCredentialService = new VisualPreviewOAuthCredentialService(); + +export function visualPreviewCredentialFromUser(user: GitHubUser): VisualPreviewOAuthCredentialInput | null { + const accessToken = user.accessToken?.trim(); + if (!accessToken || !isSupportedVisualPreviewUploadToken(accessToken)) return null; + return { + githubUserId: user.id, + githubUsername: user.username, + source: user.oauthSource || 'github', + accessToken, + refreshToken: user.refreshToken, + accessTokenExpiresAt: user.tokenExpiresAt, + refreshTokenExpiresAt: user.refreshTokenExpiresAt, + }; +} + +export async function captureVisualPreviewCredentialFromAdminLogin(user: GitHubUser): Promise { + if (!isUserWhitelisted(user.username)) return false; + const credential = visualPreviewCredentialFromUser(user); + if (!credential) return false; + const authorization = await resolveInstanceAuthorization(user); + if (authorization.role !== 'admin') return false; + return visualPreviewOAuthCredentialService.captureFromLogin(credential); +} + +export async function updateVisualPreviewCredentialForCurrentOwner(user: GitHubUser): Promise { + const credential = visualPreviewCredentialFromUser(user); + if (!credential) return false; + return visualPreviewOAuthCredentialService.updateIfOwner(credential); +} + +export interface VisualPreviewOAuthRefreshScheduler { + close: () => Promise; +} + +export async function startVisualPreviewOAuthRefreshScheduler( + service = visualPreviewOAuthCredentialService, +): Promise { + let closed = false; + let activeRefresh: Promise | undefined; + const refresh = (): Promise => { + if (activeRefresh) return activeRefresh; + activeRefresh = service.refreshIfNeeded() + .then(result => { + if (result === 'refreshed') console.log('[visual-preview] Refreshed the GitHub OAuth upload credential'); + if (result === 'reauth-required') console.warn('[visual-preview] GitHub OAuth upload credential requires reconnection'); + }) + .catch(error => { + console.warn('[visual-preview] Could not refresh the GitHub OAuth upload credential:', (error as Error).message); + }) + .finally(() => { activeRefresh = undefined; }); + return activeRefresh; + }; + + await refresh(); + const timer = setInterval(() => { if (!closed) void refresh(); }, REFRESH_INTERVAL_MS); + timer.unref(); + return { + close: async () => { + closed = true; + clearInterval(timer); + await activeRefresh; + }, + }; +} diff --git a/packages/api/test/authGithubTokens.test.ts b/packages/api/test/authGithubTokens.test.ts index 499f69343..42120fa7d 100644 --- a/packages/api/test/authGithubTokens.test.ts +++ b/packages/api/test/authGithubTokens.test.ts @@ -145,6 +145,40 @@ test('ensureAuthenticated reports a temporary error when refresh fails recoverab assert.equal(req.destroyCalls, 0); }); +test('refreshes a Connect-issued session through the relay', async () => { + configureDemoMode(false); + const previousRelayUrl = process.env.PROPR_GH_RELAY_URL; + const previousRelayToken = process.env.PROPR_GH_RELAY_TOKEN; + process.env.PROPR_GH_RELAY_URL = 'https://relay.example.test/v1'; + process.env.PROPR_GH_RELAY_TOKEN = 'prt_relay'; + const req = createRequest(createUser({ + accessToken: 'connect-access-token', + oauthSource: 'connect', + })); + const { response } = createJsonResponse(); + let refreshRequest: Request | undefined; + globalThis.fetch = async (input, init) => { + refreshRequest = new Request(input, init); + return Response.json({ + access_token: 'gho_fresh-connect-token', + refresh_token: 'ghr_fresh-connect-refresh', + expires_in: 3600, + }); + }; + + try { + assert.equal(await runEnsureAuthenticated(req, response), true); + assert.equal(refreshRequest?.url, 'https://relay.example.test/v1/auth/instance-grants/refresh'); + assert.equal(refreshRequest?.headers.get('authorization'), 'Bearer prt_relay'); + assert.deepEqual(JSON.parse(await refreshRequest!.text()), { refresh_token: 'refresh-token' }); + } finally { + if (previousRelayUrl === undefined) delete process.env.PROPR_GH_RELAY_URL; + else process.env.PROPR_GH_RELAY_URL = previousRelayUrl; + if (previousRelayToken === undefined) delete process.env.PROPR_GH_RELAY_TOKEN; + else process.env.PROPR_GH_RELAY_TOKEN = previousRelayToken; + } +}); + test('ensureAuthenticated coalesces concurrent expired-token refreshes for one session', async () => { configureDemoMode(false); const req1 = createRequest(createUser({ accessToken: 'expired-token-1' })); diff --git a/packages/api/test/authRedirect.test.ts b/packages/api/test/authRedirect.test.ts index 900b3e446..6d4dc12a4 100644 --- a/packages/api/test/authRedirect.test.ts +++ b/packages/api/test/authRedirect.test.ts @@ -10,6 +10,8 @@ const originalFrontendUrl = process.env.FRONTEND_URL; const originalCookieDomain = process.env.COOKIE_DOMAIN; const originalApiPublicUrl = process.env.API_PUBLIC_URL; const originalRedirectAllowedHosts = process.env.AUTH_REDIRECT_ALLOWED_HOSTS; +const originalAuthRateLimitMax = process.env.PROPR_AUTH_RATE_LIMIT_MAX; +const originalAuthRateLimitWindowMs = process.env.PROPR_AUTH_RATE_LIMIT_WINDOW_MS; async function fetchFromApp(app: express.Express, path: string): Promise { const server = app.listen(0, '127.0.0.1'); @@ -35,12 +37,38 @@ afterEach(() => { else process.env.API_PUBLIC_URL = originalApiPublicUrl; if (originalRedirectAllowedHosts === undefined) delete process.env.AUTH_REDIRECT_ALLOWED_HOSTS; else process.env.AUTH_REDIRECT_ALLOWED_HOSTS = originalRedirectAllowedHosts; + if (originalAuthRateLimitMax === undefined) delete process.env.PROPR_AUTH_RATE_LIMIT_MAX; + else process.env.PROPR_AUTH_RATE_LIMIT_MAX = originalAuthRateLimitMax; + if (originalAuthRateLimitWindowMs === undefined) delete process.env.PROPR_AUTH_RATE_LIMIT_WINDOW_MS; + else process.env.PROPR_AUTH_RATE_LIMIT_WINDOW_MS = originalAuthRateLimitWindowMs; }); after(async () => { await closeConnection(); }); +test('ordinary auth metadata requests do not consume the OAuth attempt quota', async () => { + process.env.PROPR_DEMO_MODE = 'true'; + process.env.FRONTEND_URL = 'https://app.example.com'; + process.env.PROPR_AUTH_RATE_LIMIT_MAX = '2'; + process.env.PROPR_AUTH_RATE_LIMIT_WINDOW_MS = '60000'; + const app = express(); + setupAuth(app); + + for (let index = 0; index < 5; index += 1) { + assert.equal((await fetchFromApp(app, '/api/auth/demo-mode')).status, 200); + } + + assert.equal((await fetchFromApp(app, '/api/auth/github')).status, 302); + assert.equal((await fetchFromApp(app, '/api/auth/github/callback')).status, 302); + const limited = await fetchFromApp(app, '/api/auth/github'); + assert.equal(limited.status, 429); + assert.deepEqual(await limited.json(), { + code: 'RATE_LIMIT_EXCEEDED', + error: 'Too many requests. Please try again later.', + }); +}); + test('auth redirect allowlist treats FRONTEND_URL as exact host only', async () => { process.env.PROPR_DEMO_MODE = 'true'; process.env.FRONTEND_URL = 'https://app.example.com'; diff --git a/packages/api/test/configRepoRoutes.test.ts b/packages/api/test/configRepoRoutes.test.ts index 96ca416e9..8cf5e5559 100644 --- a/packages/api/test/configRepoRoutes.test.ts +++ b/packages/api/test/configRepoRoutes.test.ts @@ -43,7 +43,8 @@ test('GET repository config returns false for legacy entries with a missing opti id: 'repo-1', name: 'integry/propr', enabled: true, - autoFollowupOnFailedCi: false + autoFollowupOnFailedCi: false, + visualPreview: { enabled: false, types: ['image'] } }] }); }); @@ -86,6 +87,7 @@ test('POST repository config persists an enabled option without enabling other r name: 'integry/propr', enabled: true, autoFollowupOnFailedCi: true, + visualPreview: { enabled: false, types: ['image'] }, alias: undefined, baseBranch: undefined, defaultBranch: undefined @@ -95,6 +97,7 @@ test('POST repository config persists an enabled option without enabling other r name: 'integry/other', enabled: true, autoFollowupOnFailedCi: false, + visualPreview: { enabled: false, types: ['image'] }, alias: undefined, baseBranch: undefined, defaultBranch: undefined @@ -102,6 +105,69 @@ test('POST repository config persists an enabled option without enabling other r ]); }); +test('POST repository config synchronizes changed visual previews across branch entries', async () => { + const saveMonitoredRepos = mock.fn(async () => true); + const routes = createConfigRoutes({ + redisClient: { + set: mock.fn(async () => 'OK'), + eval: mock.fn(async () => 1), + publish: mock.fn(async () => 1), + lPush: mock.fn(async () => 1), + lTrim: mock.fn(async () => 'OK') + } as never, + configStore: { + loadMonitoredReposRaw: async () => [ + { + id: 'repo-main', + name: 'integry/propr', + enabled: true, + baseBranch: 'main', + visualPreview: { enabled: false, types: ['image'] } + }, + { + id: 'repo-release', + name: 'integry/propr', + enabled: true, + baseBranch: 'release', + visualPreview: { enabled: false, types: ['image'] } + } + ], + saveMonitoredRepos, + clearRemovedRepositoryIndexData: async () => {} + }, + database: { + transaction: async (callback: (transaction: never) => Promise) => callback({} as never) + } as never + }); + const response = createResponse(); + const visualPreview = { + enabled: true, + types: ['image', 'video'], + instructions: 'Show desktop and mobile.' + }; + + await routes.postRepos({ + body: { + repos_to_monitor: [ + { id: 'repo-main', name: 'integry/propr', enabled: true, baseBranch: 'main', visualPreview }, + { + id: 'repo-release', + name: 'integry/propr', + enabled: true, + baseBranch: 'release', + visualPreview: { enabled: false, types: ['image'] } + } + ] + } + } as never, response as never); + + assert.equal(response.statusCode, 200); + assert.deepEqual( + saveMonitoredRepos.mock.calls[0]?.arguments[0].map(repo => repo.visualPreview), + [visualPreview, visualPreview] + ); +}); + test('POST repository config preserves an omitted option for existing repositories', async () => { const saveMonitoredRepos = mock.fn(async () => true); const routes = createConfigRoutes({ diff --git a/packages/api/test/configRepoValidation.test.ts b/packages/api/test/configRepoValidation.test.ts index 19dc67e89..7b000c453 100644 --- a/packages/api/test/configRepoValidation.test.ts +++ b/packages/api/test/configRepoValidation.test.ts @@ -12,6 +12,63 @@ test('repository config defaults missing automatic failed-CI follow-up to false' assert.equal(normalized.ok, true); if (normalized.ok) { assert.equal(normalized.value.autoFollowupOnFailedCi, false); + assert.deepEqual(normalized.value.visualPreview, { enabled: false, types: ['image'] }); + } +}); + +test('repository config accepts visual preview types and trims instructions', () => { + const normalized = normalizeRepoConfig({ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + visualPreview: { + enabled: true, + types: ['video', 'image', 'video'], + instructions: ' Capture desktop and mobile views. ' + } + }); + + assert.equal(normalized.ok, true); + if (normalized.ok) { + assert.deepEqual(normalized.value.visualPreview, { + enabled: true, + types: ['video', 'image'], + instructions: 'Capture desktop and mobile views.' + }); + } +}); + +test('repository config defaults omitted visual preview types', () => { + const normalized = normalizeRepoConfig({ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + visualPreview: { enabled: false } + }); + + assert.equal(normalized.ok, true); + if (normalized.ok) { + assert.deepEqual(normalized.value.visualPreview, { enabled: false, types: ['image'] }); + } +}); + +test('repository config rejects invalid visual preview settings', () => { + const invalidValues = [ + { enabled: 'true', types: ['image'] }, + { enabled: true, types: [] }, + { enabled: true, types: ['animation'] }, + { enabled: true, types: ['image'], instructions: 42 } + ]; + + for (const visualPreview of invalidValues) { + const normalized = normalizeRepoConfig({ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + visualPreview + }); + assert.equal(normalized.ok, false); + if (!normalized.ok) assert.match(normalized.error, /visualPreview/); } }); diff --git a/packages/api/test/connectAuth.test.ts b/packages/api/test/connectAuth.test.ts index a27be828c..a7c7fd44a 100644 --- a/packages/api/test/connectAuth.test.ts +++ b/packages/api/test/connectAuth.test.ts @@ -145,3 +145,30 @@ test('binds the Connect identity username to the validated token owner', async ( assert.equal(user.username, 'verified-owner'); assert.equal(user.displayName, 'verified-owner'); }); + +test('preserves expiring OAuth grant fields returned by Connect', async () => { + const before = Date.now(); + const user = await redeemConnectAuthorizationCode({ + code: 'pia_code', + relayUrl: 'https://webhook.propr.dev/v1', + relayToken: 'prt_relay_secret', + fetchImpl: (async (input) => { + if (String(input) === 'https://api.github.com/user') { + return Response.json({ id: 583231, login: 'octocat' }); + } + return Response.json({ + username: 'octocat', + avatar_url: null, + access_token: 'gho_user_secret', + refresh_token: 'ghr_refresh_secret', + expires_in: 28_800, + refresh_token_expires_in: 15_897_600, + }); + }) as typeof fetch, + }); + + assert.equal(user.oauthSource, 'connect'); + assert.equal(user.refreshToken, 'ghr_refresh_secret'); + assert.ok((user.tokenExpiresAt || 0) >= before + 28_800_000); + assert.ok((user.refreshTokenExpiresAt || 0) >= before + 15_897_600_000); +}); diff --git a/packages/api/test/routeAuthorization.test.ts b/packages/api/test/routeAuthorization.test.ts index 6c314ac3c..5da652019 100644 --- a/packages/api/test/routeAuthorization.test.ts +++ b/packages/api/test/routeAuthorization.test.ts @@ -52,6 +52,7 @@ function createAuthorizationTestApp() { agentRuntimeRoutes: handlerCollection(), agentVersionRoutes: handlerCollection(), configRoutes: handlerCollection(), + visualPreviewAuthRoutes: handlerCollection(), }), ]; assertNoDuplicateRoutes(routes); @@ -82,6 +83,10 @@ const managementRequests = [ ['POST', '/api/config/synthetic-agents'], ['GET', '/api/config/agent-tank/usage'], ['GET', '/api/admin/members'], + ['GET', '/api/config/visual-preview-auth'], + ['POST', '/api/config/visual-preview-auth'], + ['PUT', '/api/config/visual-preview-auth/token'], + ['DELETE', '/api/config/visual-preview-auth'], ['GET', '/api/agent-runtime/packages'], ['POST', '/api/agent-runtime/packages/verify'], ['GET', '/api/agents/codex/images'], diff --git a/packages/api/test/visualPreviewAuthRoutes.test.ts b/packages/api/test/visualPreviewAuthRoutes.test.ts new file mode 100644 index 000000000..3afbc6700 --- /dev/null +++ b/packages/api/test/visualPreviewAuthRoutes.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import type { Request, Response } from 'express'; +import { closeConnection } from '@propr/core'; +import type { + VisualPreviewOAuthCredentialInput, + VisualPreviewOAuthCredentialService, +} from '@propr/core'; +import { createVisualPreviewAuthRoutes } from '../routes/visualPreviewAuthRoutes.js'; +import type { GitHubUser } from '../authTypes.js'; + +after(async () => closeConnection()); + +function user(overrides: Partial = {}): GitHubUser { + return { + id: '123', + login: 'admin', + username: 'admin', + displayName: 'Admin', + email: null, + avatarUrl: null, + accessToken: 'gho_browser-secret', + refreshToken: 'ghr_browser-secret', + oauthSource: 'github', + ...overrides, + }; +} + +function responseRecorder() { + let statusCode = 200; + let body: unknown; + const response = { + status(code: number) { + statusCode = code; + return response; + }, + json(value: unknown) { + body = value; + return response; + }, + end() { return response; }, + } as unknown as Response; + return { response, getStatus: () => statusCode, getBody: () => body }; +} + +test('returns visual-preview auth status without exposing stored token material', async () => { + const service = { + getStatus: async () => ({ + configured: true, + source: 'github' as const, + status: 'active' as const, + githubUsername: 'admin', + }), + } as unknown as VisualPreviewOAuthCredentialService; + const routes = createVisualPreviewAuthRoutes({ service }); + const recorder = responseRecorder(); + + await routes.getStatus({ user: user() } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 200); + assert.deepEqual(recorder.getBody(), { + configured: true, + source: 'github', + status: 'active', + githubUsername: 'admin', + currentUsername: 'admin', + currentLoginTokenType: 'supported', + canUseCurrentLogin: true, + }); + assert.doesNotMatch(JSON.stringify(recorder.getBody()), /browser-secret/); +}); + +test('explicitly replaces the uploader grant with the current administrator login', async () => { + let replaced: VisualPreviewOAuthCredentialInput | undefined; + const service = { + replace: async (credential: VisualPreviewOAuthCredentialInput) => { replaced = credential; }, + getStatus: async () => ({ configured: true, source: 'github' as const, status: 'active' as const }), + } as unknown as VisualPreviewOAuthCredentialService; + const routes = createVisualPreviewAuthRoutes({ service }); + const recorder = responseRecorder(); + + await routes.useCurrentLogin({ user: user() } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 200); + assert.equal(replaced?.githubUserId, '123'); + assert.equal(replaced?.accessToken, 'gho_browser-secret'); + assert.doesNotMatch(JSON.stringify(recorder.getBody()), /browser-secret/); +}); + +test('rejects a current login whose GitHub token cannot upload attachments', async () => { + const routes = createVisualPreviewAuthRoutes({ service: {} as VisualPreviewOAuthCredentialService }); + const recorder = responseRecorder(); + + await routes.useCurrentLogin({ user: user({ accessToken: 'ghs_installation-token' }) } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 409); + assert.deepEqual(recorder.getBody(), { + error: 'The current GitHub login did not provide an OAuth App token or personal access token supported by GitHub attachment uploads.', + code: 'VISUAL_PREVIEW_LOGIN_TOKEN_UNSUPPORTED', + }); +}); + +test('identifies a GitHub App user login without exposing its token', async () => { + const service = { + getStatus: async () => ({ configured: false, status: 'missing' as const }), + } as unknown as VisualPreviewOAuthCredentialService; + const routes = createVisualPreviewAuthRoutes({ service }); + const recorder = responseRecorder(); + + await routes.getStatus({ user: user({ accessToken: 'ghu_browser-secret' }) } as Request, recorder.response); + + assert.deepEqual(recorder.getBody(), { + configured: false, + status: 'missing', + currentUsername: 'admin', + currentLoginTokenType: 'github_app_user', + canUseCurrentLogin: false, + }); + assert.doesNotMatch(JSON.stringify(recorder.getBody()), /browser-secret/); +}); + +test('explains why a GitHub App user login cannot be selected', async () => { + const routes = createVisualPreviewAuthRoutes({ service: {} as VisualPreviewOAuthCredentialService }); + const recorder = responseRecorder(); + + await routes.useCurrentLogin({ user: user({ accessToken: 'ghu_browser-secret' }) } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 409); + assert.deepEqual(recorder.getBody(), { + error: 'The current login uses a GitHub App user token, which GitHub attachment uploads reject. Add a personal access token in Visual preview uploads instead.', + code: 'VISUAL_PREVIEW_LOGIN_TOKEN_UNSUPPORTED', + }); +}); + +test('validates and stores a submitted personal access token without returning it', async () => { + let replaced: VisualPreviewOAuthCredentialInput | undefined; + let authorization = ''; + const service = { + getStatus: async () => replaced + ? { configured: true, source: 'static_token' as const, status: 'active' as const, githubUsername: 'preview-bot' } + : { configured: false, status: 'missing' as const }, + replace: async (credential: VisualPreviewOAuthCredentialInput) => { replaced = credential; }, + } as unknown as VisualPreviewOAuthCredentialService; + const fetchImpl = (async (_input, init) => { + authorization = new Headers(init?.headers).get('authorization') || ''; + return Response.json({ id: 456, login: 'preview-bot' }); + }) as typeof fetch; + const routes = createVisualPreviewAuthRoutes({ service, fetchImpl }); + const recorder = responseRecorder(); + + await routes.usePersonalAccessToken({ + user: user({ accessToken: 'ghu_browser-secret' }), + body: { token: 'github_pat_preview-secret' }, + } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 200); + assert.equal(authorization, 'Bearer github_pat_preview-secret'); + assert.deepEqual(replaced, { + githubUserId: '456', + githubUsername: 'preview-bot', + source: 'static_token', + accessToken: 'github_pat_preview-secret', + }); + assert.doesNotMatch(JSON.stringify(recorder.getBody()), /preview-secret/); +}); + +test('rejects unsupported submitted tokens before contacting GitHub', async () => { + let fetched = false; + const routes = createVisualPreviewAuthRoutes({ + service: {} as VisualPreviewOAuthCredentialService, + fetchImpl: (async () => { fetched = true; return Response.json({}); }) as typeof fetch, + }); + const recorder = responseRecorder(); + + await routes.usePersonalAccessToken({ body: { token: 'ghu_app-user-token' } } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 400); + assert.equal((recorder.getBody() as { code: string }).code, 'VISUAL_PREVIEW_TOKEN_UNSUPPORTED'); + assert.equal(fetched, false); +}); + +test('does not replace an environment-managed preview token', async () => { + let fetched = false; + const service = { + getStatus: async () => ({ configured: true, source: 'environment' as const, status: 'active' as const }), + } as unknown as VisualPreviewOAuthCredentialService; + const routes = createVisualPreviewAuthRoutes({ + service, + fetchImpl: (async () => { fetched = true; return Response.json({}); }) as typeof fetch, + }); + const recorder = responseRecorder(); + + await routes.usePersonalAccessToken({ body: { token: 'ghp_preview-secret' } } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 409); + assert.equal((recorder.getBody() as { code: string }).code, 'VISUAL_PREVIEW_TOKEN_ENVIRONMENT_MANAGED'); + assert.equal(fetched, false); +}); + +test('reports a token rejected by GitHub without storing it', async () => { + let replaced = false; + const service = { + getStatus: async () => ({ configured: false, status: 'missing' as const }), + replace: async () => { replaced = true; }, + } as unknown as VisualPreviewOAuthCredentialService; + const routes = createVisualPreviewAuthRoutes({ + service, + fetchImpl: (async () => new Response(null, { status: 401 })) as typeof fetch, + }); + const recorder = responseRecorder(); + + await routes.usePersonalAccessToken({ body: { token: 'ghp_preview-secret' } } as Request, recorder.response); + + assert.equal(recorder.getStatus(), 400); + assert.equal((recorder.getBody() as { code: string }).code, 'VISUAL_PREVIEW_TOKEN_INVALID'); + assert.equal(replaced, false); +}); diff --git a/packages/cli/src/api/index.ts b/packages/cli/src/api/index.ts index 050dd105e..3975e0a53 100644 --- a/packages/cli/src/api/index.ts +++ b/packages/cli/src/api/index.ts @@ -38,6 +38,12 @@ export { updateAgentRuntimePackages, verifyAgentRuntimePackages, } from './agentRuntime.js'; + +export { + getVisualPreviewAuthStatus, + saveVisualPreviewUploadToken, +} from './visualPreviewAuth.js'; +export type { VisualPreviewAuthStatus } from './visualPreviewAuth.js'; export type { AgentRuntimeBuildStatus, AgentRuntimeImageVerification, @@ -132,6 +138,7 @@ export { export type { MonitoredRepo, + VisualPreviewSettings, GetReposResponse, AddRepoOptions, UpdateRepoOptions, diff --git a/packages/cli/src/api/repos.test.ts b/packages/cli/src/api/repos.test.ts index 84f39dd78..7e7f95e83 100644 --- a/packages/cli/src/api/repos.test.ts +++ b/packages/cli/src/api/repos.test.ts @@ -4,9 +4,9 @@ import type { ApiClient } from './client.js'; import { addRepo, updateRepo, type MonitoredRepo } from './repos.js'; function createClient(repos: MonitoredRepo[]): { client: ApiClient; postedRepos: () => MonitoredRepo[] } { - let savedRepos: MonitoredRepo[] = []; + let savedRepos = repos; const client = { - get: async () => ({ data: { repos_to_monitor: repos } }), + get: async () => ({ data: { repos_to_monitor: savedRepos } }), post: async (_path: string, options: { body: { repos_to_monitor: MonitoredRepo[] } }) => { savedRepos = options.body.repos_to_monitor; return { data: { success: true, repos_to_monitor: savedRepos } }; @@ -28,6 +28,33 @@ test('addRepo preserves existing failed-CI options and defaults the new reposito assert.equal(postedRepos()[0]?.autoFollowupOnFailedCi, true); assert.equal(postedRepos()[1]?.autoFollowupOnFailedCi, false); + assert.deepEqual(postedRepos()[1]?.visualPreview, { enabled: false, types: ['image'] }); +}); + +test('updateRepo merges visual preview fields without dropping existing instructions', async () => { + const { client, postedRepos } = createClient([{ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi: false, + visualPreview: { enabled: false, types: ['image'], instructions: 'Show mobile.' } + }]); + + await updateRepo('integry/propr', { + visualPreview: { enabled: true, types: ['image', 'video'] } + }, client); + + assert.deepEqual(postedRepos()[0]?.visualPreview, { + enabled: true, + types: ['image', 'video'], + instructions: 'Show mobile.' + }); + + await updateRepo('integry/propr', { visualPreview: { instructions: null } }, client); + assert.deepEqual(postedRepos()[0]?.visualPreview, { + enabled: true, + types: ['image', 'video'] + }); }); test('updateRepo writes the failed-CI option without changing other repositories', async () => { diff --git a/packages/cli/src/api/repos.ts b/packages/cli/src/api/repos.ts index 149f07750..d1a34c52a 100644 --- a/packages/cli/src/api/repos.ts +++ b/packages/cli/src/api/repos.ts @@ -171,6 +171,11 @@ export interface MonitoredRepo { */ autoFollowupOnFailedCi: boolean; + /** + * Visual evidence generated for changes with a user-visible result. + */ + visualPreview?: VisualPreviewSettings; + /** * Optional display alias for the repository. */ @@ -182,6 +187,12 @@ export interface MonitoredRepo { baseBranch?: string; } +export interface VisualPreviewSettings { + enabled: boolean; + types: Array<'image' | 'video'>; + instructions?: string; +} + /** * Response from the get repos endpoint. */ @@ -215,6 +226,9 @@ export interface AddRepoOptions { * Whether failed CI should trigger automatic follow-up work. Defaults to false. */ autoFollowupOnFailedCi?: boolean; + + /** Visual preview policy. Defaults to disabled with image capture selected. */ + visualPreview?: VisualPreviewSettings; } /** @@ -240,6 +254,9 @@ export interface UpdateRepoOptions { * Optional new automatic failed-CI follow-up state. */ autoFollowupOnFailedCi?: boolean; + + /** Optional visual preview policy update. */ + visualPreview?: Omit, 'instructions'> & { instructions?: string | null }; } /** @@ -324,6 +341,7 @@ export async function addRepo( name: fullName, enabled: options.enabled ?? true, autoFollowupOnFailedCi: options.autoFollowupOnFailedCi ?? false, + visualPreview: options.visualPreview ?? { enabled: false, types: ['image'] }, alias: options.alias?.trim() || undefined, baseBranch: options.baseBranch?.trim() || undefined, }; @@ -379,10 +397,20 @@ export async function updateRepo( // Apply updates const existingRepo = currentRepos.repos_to_monitor[repoIndex]; + const updatedInstructions = updates.visualPreview?.instructions === undefined + ? existingRepo.visualPreview?.instructions + : updates.visualPreview.instructions?.trim() || undefined; const updatedRepo: MonitoredRepo = { ...existingRepo, ...(updates.enabled !== undefined && { enabled: updates.enabled }), ...(updates.autoFollowupOnFailedCi !== undefined && { autoFollowupOnFailedCi: updates.autoFollowupOnFailedCi }), + ...(updates.visualPreview !== undefined && { + visualPreview: { + enabled: updates.visualPreview.enabled ?? existingRepo.visualPreview?.enabled ?? false, + types: updates.visualPreview.types ?? existingRepo.visualPreview?.types ?? ['image'], + ...(updatedInstructions ? { instructions: updatedInstructions } : {}) + } + }), ...(updates.alias !== undefined && { alias: updates.alias?.trim() || undefined }), ...(updates.baseBranch !== undefined && { baseBranch: updates.baseBranch?.trim() || undefined }), }; diff --git a/packages/cli/src/api/visualPreviewAuth.ts b/packages/cli/src/api/visualPreviewAuth.ts new file mode 100644 index 000000000..2254d13d6 --- /dev/null +++ b/packages/cli/src/api/visualPreviewAuth.ts @@ -0,0 +1,21 @@ +import type { ApiClient } from './client.js'; + +export interface VisualPreviewAuthStatus { + configured: boolean; + source?: 'github' | 'connect' | 'static_token' | 'environment'; + status: 'active' | 'reauth_required' | 'missing'; + githubUsername?: string; +} + +export async function getVisualPreviewAuthStatus(client: ApiClient): Promise { + return (await client.get('/api/config/visual-preview-auth')).data; +} + +export async function saveVisualPreviewUploadToken( + token: string, + client: ApiClient, +): Promise { + return (await client.put('/api/config/visual-preview-auth/token', { + body: { token }, + })).data; +} diff --git a/packages/cli/src/commands/repoCommands.test.ts b/packages/cli/src/commands/repoCommands.test.ts index 9297c8297..2a51c10e4 100644 --- a/packages/cli/src/commands/repoCommands.test.ts +++ b/packages/cli/src/commands/repoCommands.test.ts @@ -89,3 +89,25 @@ test("repo toggle accepts positive and negative automatic CI follow-up flags", a assert.equal(disabled[0]?.enabled, false); assert.equal(disabled[1]?.autoFollowupOnFailedCi, true); }); + +test("repo add and toggle configure visual preview policy", async () => { + const added = await runRepoWrite( + ["add", "integry/previewed", "--visual-previews", "--preview-types", "image,video", "--preview-instructions", "Show desktop and mobile."], + [] + ); + assert.deepEqual(added[0]?.visualPreview, { + enabled: true, + types: ["image", "video"], + instructions: "Show desktop and mobile." + }); + + const disabled = await runRepoWrite( + ["toggle", "integry/previewed", "--no-visual-previews"], + added + ); + assert.deepEqual(disabled[0]?.visualPreview, { + enabled: false, + types: ["image", "video"], + instructions: "Show desktop and mobile." + }); +}); diff --git a/packages/cli/src/commands/repoCommands.ts b/packages/cli/src/commands/repoCommands.ts index a8b1efff0..8b617a742 100644 --- a/packages/cli/src/commands/repoCommands.ts +++ b/packages/cli/src/commands/repoCommands.ts @@ -15,6 +15,7 @@ import { getIndexingStatus, MonitoredRepo, RepositoryIndexingStatus, + VisualPreviewSettings, } from "../api/index.js"; import { printOutput } from "../utils/index.js"; import { classifyApiError, presentApiError } from "../utils/apiErrorPresentation.js"; @@ -26,6 +27,19 @@ function formatEnabled(enabled: boolean): string { return enabled ? "Enabled" : "Disabled"; } +function parseVisualPreviewTypes(value: string | undefined): VisualPreviewSettings['types'] { + if (!value) return ['image']; + const values = [...new Set(value.split(',').map(type => type.trim().toLowerCase()).filter(Boolean))]; + if (values.length === 0 || values.some(type => type !== 'image' && type !== 'video')) { + throw new Error('Preview types must be a comma-separated list containing image and/or video'); + } + return values as VisualPreviewSettings['types']; +} + +function formatVisualPreview(settings: VisualPreviewSettings | undefined): string { + return settings?.enabled ? settings.types.join('+') : 'Disabled'; +} + /** * Truncates a string to a maximum length. */ @@ -158,6 +172,10 @@ function displayReposTable(repos: MonitoredRepo[]): void { "Auto CI follow-up".length, ...repos.map((r) => formatEnabled(r.autoFollowupOnFailedCi).length) ); + const visualPreviewWidth = Math.max( + "Visual previews".length, + ...repos.map((r) => formatVisualPreview(r.visualPreview).length) + ); const header = [ "Repository".padEnd(nameWidth), @@ -165,6 +183,7 @@ function displayReposTable(repos: MonitoredRepo[]): void { "Branch".padEnd(branchWidth), "Status".padEnd(statusWidth), "Auto CI follow-up".padEnd(autoCiFollowupWidth), + "Visual previews".padEnd(visualPreviewWidth), ].join(" "); console.log(header); @@ -177,6 +196,7 @@ function displayReposTable(repos: MonitoredRepo[]): void { (truncate(repo.baseBranch, 20) || "-").padEnd(branchWidth), formatEnabled(repo.enabled).padEnd(statusWidth), formatEnabled(repo.autoFollowupOnFailedCi).padEnd(autoCiFollowupWidth), + formatVisualPreview(repo.visualPreview).padEnd(visualPreviewWidth), ].join(" "); console.log(row); @@ -249,6 +269,9 @@ Examples: .option("-a, --alias ", "Display alias for the repository") .option("-b, --branch ", "Base branch name (default: main/master)") .option("--auto-ci-followup", "Enable automatic follow-up when CI fails (default: off)") + .option("--visual-previews", "Enable visual previews for user-visible changes") + .option("--preview-types ", "Comma-separated preview types: image,video") + .option("--preview-instructions ", "Additional visual capture instructions") .addHelpText("after", ` Argument: fullName Repository in owner/repo format @@ -257,11 +280,12 @@ Examples: $ propr repo add myorg/myrepo $ propr repo add myorg/myrepo -a "My Project" -b develop $ propr repo add myorg/myrepo --auto-ci-followup + $ propr repo add myorg/myrepo --visual-previews --preview-types image,video `) .action( async ( fullName: string, - options: { alias?: string; branch?: string; autoCiFollowup?: boolean } + options: { alias?: string; branch?: string; autoCiFollowup?: boolean; visualPreviews?: boolean; previewTypes?: string; previewInstructions?: string } ) => { try { if (!fullName.includes("/")) { @@ -283,11 +307,18 @@ Examples: console.log(`Adding repository: ${fullName}...`); + const previewRequested = options.visualPreviews === true || options.previewTypes !== undefined || options.previewInstructions !== undefined; + const result = await addRepo(fullName, { alias: options.alias, baseBranch: options.branch, enabled: true, autoFollowupOnFailedCi: options.autoCiFollowup ?? false, + visualPreview: { + enabled: previewRequested, + types: parseVisualPreviewTypes(options.previewTypes), + ...(options.previewInstructions?.trim() ? { instructions: options.previewInstructions.trim() } : {}) + }, }); if (result.success) { @@ -302,6 +333,10 @@ Examples: console.log( ` Automatic CI follow-up: ${formatEnabled(options.autoCiFollowup ?? false)}` ); + console.log(` Visual previews: ${formatVisualPreview({ + enabled: previewRequested, + types: parseVisualPreviewTypes(options.previewTypes) + })}`); console.log(""); console.log( `Total monitored repositories: ${result.repos_to_monitor.length}` @@ -409,28 +444,33 @@ Example: // repo toggle repo .command("toggle ") - .description("Update monitoring or automatic CI follow-up for a repository") + .description("Update monitoring, automatic CI follow-up, or visual previews for a repository") .option("--enable", "Enable monitoring for the repository") .option("--disable", "Disable monitoring for the repository") .option("--auto-ci-followup", "Enable automatic follow-up when CI fails") .option("--no-auto-ci-followup", "Disable automatic follow-up when CI fails") + .option("--visual-previews", "Enable visual previews") + .option("--no-visual-previews", "Disable visual previews") + .option("--preview-types ", "Comma-separated preview types: image,video") + .option("--preview-instructions ", "Replace visual capture instructions") .addHelpText("after", ` Argument: fullName Repository in owner/repo format Note: - Specify at least one monitoring or automatic CI follow-up option. + Specify at least one monitoring, automatic CI follow-up, or visual preview option. Examples: $ propr repo toggle myorg/myrepo --enable $ propr repo toggle myorg/myrepo --disable $ propr repo toggle myorg/myrepo --auto-ci-followup $ propr repo toggle myorg/myrepo --no-auto-ci-followup + $ propr repo toggle myorg/myrepo --visual-previews --preview-types image,video `) .action( async ( fullName: string, - options: { enable?: boolean; disable?: boolean; autoCiFollowup?: boolean } + options: { enable?: boolean; disable?: boolean; autoCiFollowup?: boolean; visualPreviews?: boolean; previewTypes?: string; previewInstructions?: string } ) => { try { if (options.enable && options.disable) { @@ -440,9 +480,9 @@ Examples: process.exit(1); } - if (!options.enable && !options.disable && options.autoCiFollowup === undefined) { + if (!options.enable && !options.disable && options.autoCiFollowup === undefined && options.visualPreviews === undefined && options.previewTypes === undefined && options.previewInstructions === undefined) { console.error( - "Error: Must specify --enable, --disable, --auto-ci-followup, or --no-auto-ci-followup." + "Error: Must specify a monitoring, automatic CI follow-up, or visual preview option." ); console.log(""); console.log("Usage:"); @@ -450,6 +490,7 @@ Examples: console.log(` propr repo toggle ${fullName} --disable`); console.log(` propr repo toggle ${fullName} --auto-ci-followup`); console.log(` propr repo toggle ${fullName} --no-auto-ci-followup`); + console.log(` propr repo toggle ${fullName} --visual-previews --preview-types image,video`); process.exit(1); } @@ -463,6 +504,13 @@ Examples: } const enabled = options.enable ? true : options.disable ? false : undefined; + const visualPreviewUpdate = options.visualPreviews !== undefined || options.previewTypes !== undefined || options.previewInstructions !== undefined + ? { + ...(options.visualPreviews !== undefined && { enabled: options.visualPreviews }), + ...(options.previewTypes !== undefined && { types: parseVisualPreviewTypes(options.previewTypes) }), + ...(options.previewInstructions !== undefined && { instructions: options.previewInstructions.trim() }) + } + : undefined; console.log(`Updating repository settings: ${fullName}...`); const result = await updateRepo(fullName, { @@ -470,6 +518,7 @@ Examples: ...(options.autoCiFollowup !== undefined && { autoFollowupOnFailedCi: options.autoCiFollowup, }), + ...(visualPreviewUpdate && { visualPreview: visualPreviewUpdate }), }); if (result.success) { @@ -483,6 +532,12 @@ Examples: ` Automatic CI follow-up: ${formatEnabled(options.autoCiFollowup)}` ); } + if (visualPreviewUpdate) { + const previewState = options.visualPreviews === false + ? 'Disabled' + : options.previewTypes ? parseVisualPreviewTypes(options.previewTypes).join('+') : 'Updated'; + console.log(` Visual previews: ${previewState}`); + } } else { console.error("Failed to update repository."); process.exit(1); diff --git a/packages/cli/src/commands/setup/engine.test.ts b/packages/cli/src/commands/setup/engine.test.ts index 014cc21ae..7e4998d6d 100644 --- a/packages/cli/src/commands/setup/engine.test.ts +++ b/packages/cli/src/commands/setup/engine.test.ts @@ -57,6 +57,7 @@ function mockActions(overrides: Partial = {}): SetupActions { isStackRunning: async () => false, startStack: async () => undefined, checkBackendHealth: async () => ({ healthy: true, detail: "API healthy" }), + configureVisualPreviewCredential: async () => ({ status: 'already-configured' }), addRepository: async () => undefined, resolveUiUrl: async () => "http://localhost:3000", openUrl: async () => undefined, @@ -98,6 +99,25 @@ test("re-running on an initialized stack leaves it intact and completes", async assert.equal(result.completed, true); }); +test("imports an upload-compatible gh token after the backend becomes healthy", async () => { + let configuredRoot: string | undefined; + const log: string[] = []; + const result = await runSetup({ + root: "/stack", + reporter: { onLog: (line) => log.push(line) }, + actions: mockActions({ + configureVisualPreviewCredential: async (rootDir) => { + configuredRoot = rootDir; + return { status: 'configured', githubUsername: 'octocat' }; + }, + }), + }); + + assert.equal(result.completed, true); + assert.equal(configuredRoot, '/stack'); + assert.ok(log.includes('visual previews: configured from the gh CLI session (@octocat)')); +}); + test("an incomplete stack root (missing dirs) is re-scaffolded even when .env exists", async () => { let scaffolded = false; const result = await runSetup({ diff --git a/packages/cli/src/commands/setup/engine.ts b/packages/cli/src/commands/setup/engine.ts index 15700eda6..0e5289566 100644 --- a/packages/cli/src/commands/setup/engine.ts +++ b/packages/cli/src/commands/setup/engine.ts @@ -335,6 +335,11 @@ export interface BackendHealth { accessFailure?: "unauthorized" | "forbidden"; } +export interface VisualPreviewCredentialSetupResult { + status: 'configured' | 'already-configured' | 'environment-managed' | 'missing' | 'unsupported'; + githubUsername?: string; +} + /** Classify an HTTP access failure from the protected backend status route. */ export function classifyBackendAccessError(error: unknown): BackendHealth | undefined { const httpStatus = (error as { status?: unknown } | null)?.status; @@ -379,6 +384,8 @@ export interface SetupActions extends AgentSetupActions { isStackRunning(rootDir: string): Promise; startStack(params: StartStackParams): Promise; checkBackendHealth(params: BackendHealthParams): Promise; + /** Seed preview uploads from the authenticated gh CLI session when possible. */ + configureVisualPreviewCredential(rootDir: string): Promise; addRepository(selection: RepoSelection, rootDir: string): Promise; resolveUiUrl(rootDir: string): Promise; /** Open `url` in the host's default browser (best-effort; may reject). */ @@ -573,6 +580,21 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction } while (Date.now() < deadline); return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; }, + async configureVisualPreviewCredential(rootDir) { + const token = configManager?.getGithubToken()?.trim(); + if (!token) return { status: 'missing' }; + if (!/^(?:gho_|ghp_|github_pat_)/.test(token)) return { status: 'unsupported' }; + + const { getVisualPreviewAuthStatus, saveVisualPreviewUploadToken } = await import('../../api/visualPreviewAuth.js'); + const client = await localApiClient(rootDir); + const current = await getVisualPreviewAuthStatus(client); + if (current.status === 'active') { + return { status: 'already-configured', githubUsername: current.githubUsername }; + } + if (current.source === 'environment') return { status: 'environment-managed' }; + const configured = await saveVisualPreviewUploadToken(token, client); + return { status: 'configured', githubUsername: configured.githubUsername }; + }, async addRepository({ fullName, alias, baseBranch }, rootDir) { const { addRepo } = await import("../../api/repos.js"); // Point the client at this stack's API port rather than the saved remote. @@ -1349,6 +1371,27 @@ export async function runSetup(options: RunSetupOptions = {}): Promise = {}): SetupActions { isStackRunning: async () => false, startStack: async () => undefined, checkBackendHealth: async () => ({ healthy: true, detail: "API healthy" }), + configureVisualPreviewCredential: async () => ({ status: 'already-configured' }), addRepository: async () => undefined, resolveUiUrl: async () => "http://localhost:3000", openUrl: async () => undefined, diff --git a/packages/core/src/agents/AgentRegistry.ts b/packages/core/src/agents/AgentRegistry.ts index c5322f9c7..2cb7fc2c8 100644 --- a/packages/core/src/agents/AgentRegistry.ts +++ b/packages/core/src/agents/AgentRegistry.ts @@ -1,19 +1,14 @@ -import path from 'path'; -import os from 'os'; import logger from '../utils/logger.js'; import { Agent, AgentConfig } from './types.js'; import { ClaudeAgent } from './impl/ClaudeAgent.js'; import * as configManager from '../config/configManager.js'; -import { ensureAgentBundleImage, ensureAgentDockerImage, executeDockerCommand } from '../claude/docker/dockerExecutor.js'; +import { executeDockerCommand } from '../claude/docker/dockerExecutor.js'; import { closeConnection } from '../db/connection.js'; import { shutdownQueue } from '../queue/taskQueue.js'; -import { computeContentHash, getAgentCliVersionMatrix, getDefaultAgentCliVersionMatrix } from './version/versionService.js'; -import { AGENT_DEFAULT_VERSIONS } from './version/types.js'; -import { DEFAULT_AGENT_DOCKER_IMAGES } from './constants.js'; -import { loadAgentRuntimePackageState, resolveAgentRuntimeImage } from './runtime/agentRuntimePackages.js'; -import { AGENT_DEFAULTS } from '../config/modelDefinitions.js'; +import { loadAgentRuntimePackageState } from './runtime/agentRuntimePackages.js'; import { SyntheticAgentRegistry, type BeginSyntheticRoutingOptions, type SyntheticRoutingSession } from './SyntheticAgentRegistry.js'; import { createAgentFromConfig } from './createAgentFromConfig.js'; +import { resolveDefaultAgentConfig, resolveUnifiedAgentImage } from './agentImagePreparation.js'; export interface AgentRegistryOperationalStatus { unifiedAgentImage: { @@ -41,6 +36,8 @@ export class AgentRegistry { private runtimePackagesUpdatedAt: string | undefined; private runtimePackageStateCheckAfter = 0; private runtimePackageStateUnavailable = false; + private pendingRefresh: Promise | null = null; + private pendingRefreshPreparesImages = false; private pendingBackgroundRefresh: Promise | null = null; private unavailableUnifiedAgentImage: { imageTag?: string; error: string; recordedAt: string } | null = null; private unifiedAgentImageRetryTimer: NodeJS.Timeout | null = null; @@ -62,9 +59,40 @@ export class AgentRegistry { /** * Reloads configuration from configManager and instantiates agents. - * This should be called at startup and whenever configuration changes. + * This is deliberately read-only with respect to Docker images. Request and + * task paths may refresh the registry, but image preparation belongs to the + * worker's startup/configuration lifecycle. */ - async refresh(): Promise { + refresh(): Promise { + return this.requestRefresh(false); + } + + /** + * Prepares missing base and runtime-package images, then refreshes the + * registry. The main worker calls this at startup and when agent version + * configuration changes; ordinary registry consumers must use refresh(). + */ + prepareImagesAndRefresh(): Promise { + return this.requestRefresh(true); + } + + private requestRefresh(prepareImages: boolean): Promise { + if (this.pendingRefresh) { + if (!prepareImages || this.pendingRefreshPreparesImages) return this.pendingRefresh; + return this.pendingRefresh.then(() => this.requestRefresh(true)); + } + + this.pendingRefreshPreparesImages = prepareImages; + const refresh = this.refreshRegistry(prepareImages) + .finally(() => { + this.pendingRefresh = null; + this.pendingRefreshPreparesImages = false; + }); + this.pendingRefresh = refresh; + return refresh; + } + + private async refreshRegistry(prepareImages: boolean): Promise { logger.info('Refreshing agent registry...'); try { @@ -81,27 +109,32 @@ export class AgentRegistry { this.defaultAgentAlias = null; } - // Clear existing maps - this.agents.clear(); - this.agentsByAlias.clear(); - if (configs.length === 0) { // Fallback: Create default Claude agent from ENV vars if no config exists logger.info('No agents configured, creating default Claude agent from environment'); - await this.registerDefaultAgent(); + await this.registerDefaultAgent(prepareImages); await this.captureRuntimePackageStateVersion(); this.initialized = true; return; } - const bundleImage = await this.ensureUnifiedAgentImage(configs); + const bundleImage = await this.ensureUnifiedAgentImage(configs, prepareImages); if (!bundleImage) { await this.captureRuntimePackageStateVersion(); this.initialized = true; - logger.warn('Agent registry initialized without agents because the unified agent image is unavailable'); + logger.warn( + this.agents.size > 0 + ? 'Keeping existing agents because the newly configured unified image is unavailable' + : 'Agent registry initialized without agents because the unified agent image is unavailable', + ); return; } + // Resolve potentially slow image work before replacing the live + // registry, so a package/version rebuild does not interrupt tasks + // that can still use the previous image. + this.agents.clear(); + this.agentsByAlias.clear(); for (const config of configs) { if (!config.enabled) { logger.debug({ agentAlias: config.alias }, 'Skipping disabled agent'); @@ -154,10 +187,9 @@ export class AgentRegistry { const err = error as Error; logger.error({ error: err.message }, 'Failed to refresh agent registry, using default agent'); - // Fallback to default agent on error - this.agents.clear(); - this.agentsByAlias.clear(); - await this.registerDefaultAgent(); + // Fallback to the default agent only after its image resolves; a + // failed fallback leaves any previously working registry intact. + await this.registerDefaultAgent(prepareImages); await this.captureRuntimePackageStateVersion(); this.initialized = true; } @@ -264,9 +296,9 @@ export class AgentRegistry { * Ensures the registry is initialized, refreshing if necessary. * * When a runtime package state change is detected on an already-initialized - * registry, the refresh runs in the background: a refresh may pull or build - * the bundle image (minutes), and callers sit on request-serving paths, so - * they keep using the current agents until the refresh completes. + * registry, the inspect-only refresh runs in the background. The dedicated + * runtime build worker prepares changed package images before publishing the + * new state, so request-serving paths never start Docker builds themselves. */ async ensureInitialized(): Promise { if (!this.initialized) { @@ -285,10 +317,9 @@ export class AgentRegistry { return; } - // Managed bundle cleanup and development content-hash changes can - // remove an image after registry initialization. Verify the cached - // image immediately before callers resolve an agent, and synchronously - // refresh so execution never reaches Docker with a missing local tag. + // If an image disappears after initialization, synchronously reload the + // inspect-only registry state so execution never reaches Docker with a + // missing local tag. Rebuilding remains the startup/config owner's job. if (!(await this.registeredAgentImagesAvailable())) { if (!this.pendingBackgroundRefresh) { logger.warn('Refreshing agent registry because a registered agent image is no longer available locally'); @@ -364,41 +395,23 @@ export class AgentRegistry { } } - private async ensureUnifiedAgentImage(configs: AgentConfig[]): Promise { - try { - const versions = getAgentCliVersionMatrix(configs); - const result = await ensureAgentBundleImage(versions, computeContentHash()); - if (!result.success) { - logger.error({ error: result.error, imageTag: result.imageTag }, 'Failed to ensure unified agent image'); - this.unavailableUnifiedAgentImage = { - imageTag: result.imageTag, - error: result.error || 'Unified agent image is unavailable', - recordedAt: new Date().toISOString() - }; - this.scheduleUnifiedAgentImageRetry(); - return null; - } - const image = await resolveAgentRuntimeImage(result.imageTag, { buildMissing: false }); - this.clearUnifiedAgentImageRetry(); - this.unavailableUnifiedAgentImage = null; - return image; - } catch (error) { - const message = (error as Error).message; - logger.error({ error: message }, 'Failed to resolve unified agent image'); - this.unavailableUnifiedAgentImage = { - error: message, - recordedAt: new Date().toISOString() - }; - this.scheduleUnifiedAgentImageRetry(); + private async ensureUnifiedAgentImage(configs: AgentConfig[], prepareImages: boolean): Promise { + const result = await resolveUnifiedAgentImage(configs, prepareImages); + if (!result.image) { + const error = result.error || 'Unified agent image is unavailable'; + logger.error({ error, imageTag: result.imageTag }, 'Failed to resolve unified agent image'); + this.recordUnavailableUnifiedAgentImage(result.imageTag, error); return null; } + this.clearUnifiedAgentImageRetry(); + this.unavailableUnifiedAgentImage = null; + return result.image; } /** - * A transient registry pull or artifact download must not leave an initialized - * but empty registry wedged until an operator edits configuration or restarts - * the service. Retry in the background with one shared timer; refresh already - * serializes the actual pull/build through the registry's normal path. + * A consumer can initialize while the worker is still preparing the image. + * Poll the local image state with one shared timer so it becomes ready after + * startup completes; refresh() is inspect-only and cannot launch a build. */ private scheduleUnifiedAgentImageRetry(): void { if (this.unifiedAgentImageRetryTimer) return; @@ -440,62 +453,41 @@ export class AgentRegistry { * Registers a default Claude agent using environment variables. * This is the fallback when no agents are configured. */ - private async registerDefaultAgent(): Promise { - const defaultConfig: AgentConfig = { - id: 'default-claude-agent', - type: 'claude', - alias: 'default', - enabled: true, - dockerImage: process.env.AGENT_DOCKER_IMAGE || DEFAULT_AGENT_DOCKER_IMAGES.claude, - configPath: process.env.CLAUDE_CONFIG_PATH || path.join(os.homedir(), '.claude'), - supportedModels: [...AGENT_DEFAULTS.claude.defaultModels], - defaultModel: process.env.CLAUDE_MODEL || undefined, - cliVersionType: 'default', - cliVersionResolved: AGENT_DEFAULT_VERSIONS.claude - }; - - if (process.env.AGENT_DOCKER_IMAGE) { - try { - const available = await ensureAgentDockerImage(defaultConfig.type, process.env.AGENT_DOCKER_IMAGE); - if (!available) { - logger.warn({ dockerImage: process.env.AGENT_DOCKER_IMAGE }, 'Configured default agent image is not available locally and could not be pulled or built'); - } - defaultConfig.dockerImage = await resolveAgentRuntimeImage(process.env.AGENT_DOCKER_IMAGE, { buildMissing: false }); - } catch (error) { - logger.error( - { dockerImage: defaultConfig.dockerImage, error: (error as Error).message }, - 'Failed to resolve default Claude agent runtime image; registering the configured image for degraded-mode health checks', - ); - } - } else { - try { - const result = await ensureAgentBundleImage(getDefaultAgentCliVersionMatrix(), computeContentHash()); - if (!result.success) { - logger.error({ error: result.error, imageTag: result.imageTag }, 'Failed to ensure default agent image; registering fallback image for degraded-mode health checks'); - } else { - defaultConfig.dockerImage = await resolveAgentRuntimeImage(result.imageTag, { buildMissing: false }); - } - } catch (error) { - logger.error( - { dockerImage: defaultConfig.dockerImage, error: (error as Error).message }, - 'Failed to resolve default Claude agent image; registering fallback image for degraded-mode health checks', - ); - } + private async registerDefaultAgent(prepareImages: boolean): Promise { + const result = await resolveDefaultAgentConfig(prepareImages); + if (!result.config) { + const error = result.error || 'Default agent image is unavailable'; + this.recordUnavailableUnifiedAgentImage(result.imageTag, error); + logger.error({ dockerImage: result.imageTag, error }, 'Failed to resolve default Claude agent image'); + return; } - const agent = new ClaudeAgent(defaultConfig); - this.agents.set(defaultConfig.id, agent); - this.agentsByAlias.set(defaultConfig.alias, agent); + this.clearUnifiedAgentImageRetry(); + this.unavailableUnifiedAgentImage = null; + this.agents.clear(); + this.agentsByAlias.clear(); + const agent = new ClaudeAgent(result.config); + this.agents.set(result.config.id, agent); + this.agentsByAlias.set(result.config.alias, agent); await this.syntheticAgents.register(); logger.info({ - agentId: defaultConfig.id, - agentAlias: defaultConfig.alias, - dockerImage: defaultConfig.dockerImage + agentId: result.config.id, + agentAlias: result.config.alias, + dockerImage: result.config.dockerImage }, 'Default Claude agent registered'); } + private recordUnavailableUnifiedAgentImage(imageTag: string | undefined, error: string): void { + this.unavailableUnifiedAgentImage = { + imageTag, + error, + recordedAt: new Date().toISOString(), + }; + this.scheduleUnifiedAgentImageRetry(); + } + /** * Clean up resources and connections. * Should be called during shutdown or test cleanup. diff --git a/packages/core/src/agents/agentImagePreparation.ts b/packages/core/src/agents/agentImagePreparation.ts new file mode 100644 index 000000000..cbae90014 --- /dev/null +++ b/packages/core/src/agents/agentImagePreparation.ts @@ -0,0 +1,108 @@ +import os from 'node:os'; +import path from 'node:path'; +import { AGENT_DEFAULTS } from '../config/modelDefinitions.js'; +import { + agentDockerImageExists, + ensureAgentBundleImage, + ensureAgentDockerImage, +} from '../claude/docker/dockerExecutor.js'; +import { resolveAgentRuntimeImage } from './runtime/agentRuntimePackages.js'; +import type { AgentConfig } from './types.js'; +import { AGENT_DEFAULT_VERSIONS } from './version/types.js'; +import { + computeContentHash, + generateAgentBundleImageTag, + getAgentCliVersionMatrix, +} from './version/versionService.js'; + +export interface AgentImageResolution { + image?: string; + imageTag?: string; + error?: string; +} + +async function resolveBundleBaseImage( + configs: AgentConfig[], + prepareImages: boolean, +): Promise { + const versions = getAgentCliVersionMatrix(configs); + const contentHash = computeContentHash(); + const imageTag = generateAgentBundleImageTag(versions, contentHash); + if (prepareImages) { + const result = await ensureAgentBundleImage(versions, contentHash); + return result.success + ? { image: result.imageTag, imageTag: result.imageTag } + : { imageTag: result.imageTag, error: result.error || 'Unified agent image is unavailable' }; + } + return await agentDockerImageExists(imageTag) + ? { image: imageTag, imageTag } + : { imageTag, error: `Unified agent image ${imageTag} has not been prepared by the worker` }; +} + +export async function resolveUnifiedAgentImage( + configs: AgentConfig[], + prepareImages: boolean, +): Promise { + try { + const base = await resolveBundleBaseImage(configs, prepareImages); + if (!base.image) return base; + return { + image: await resolveAgentRuntimeImage(base.image, { buildMissing: prepareImages }), + imageTag: base.imageTag, + }; + } catch (error) { + return { error: (error as Error).message }; + } +} + +async function resolveConfiguredDefaultImage( + dockerImage: string, + prepareImages: boolean, +): Promise { + const available = prepareImages + ? await ensureAgentDockerImage('claude', dockerImage) + : await agentDockerImageExists(dockerImage); + if (!available) { + return { + imageTag: dockerImage, + error: prepareImages + ? 'Configured default agent image could not be pulled or built' + : 'Configured default agent image has not been prepared by the worker', + }; + } + return { + image: await resolveAgentRuntimeImage(dockerImage, { buildMissing: prepareImages }), + imageTag: dockerImage, + }; +} + +export async function resolveDefaultAgentConfig( + prepareImages: boolean, +): Promise<{ config?: AgentConfig; imageTag?: string; error?: string }> { + const configuredImage = process.env.AGENT_DOCKER_IMAGE; + let resolution: AgentImageResolution; + try { + resolution = configuredImage + ? await resolveConfiguredDefaultImage(configuredImage, prepareImages) + : await resolveUnifiedAgentImage([], prepareImages); + } catch (error) { + return { imageTag: configuredImage, error: (error as Error).message }; + } + if (!resolution.image) return resolution; + + return { + config: { + id: 'default-claude-agent', + type: 'claude', + alias: 'default', + enabled: true, + dockerImage: resolution.image, + configPath: process.env.CLAUDE_CONFIG_PATH || path.join(os.homedir(), '.claude'), + supportedModels: [...AGENT_DEFAULTS.claude.defaultModels], + defaultModel: process.env.CLAUDE_MODEL || undefined, + cliVersionType: 'default', + cliVersionResolved: AGENT_DEFAULT_VERSIONS.claude, + }, + imageTag: resolution.imageTag, + }; +} diff --git a/packages/core/src/claude/docker/dockerExecutor.ts b/packages/core/src/claude/docker/dockerExecutor.ts index 41698d18a..cbf306bd0 100644 --- a/packages/core/src/claude/docker/dockerExecutor.ts +++ b/packages/core/src/claude/docker/dockerExecutor.ts @@ -366,5 +366,5 @@ function detectContainerId( } // Re-export image builder functions for backward compatibility -export { buildClaudeDockerImage, ensureAgentBundleImage, ensureAgentDockerImage } from './dockerImageBuilder.js'; +export { agentDockerImageExists, buildClaudeDockerImage, ensureAgentBundleImage, ensureAgentDockerImage } from './dockerImageBuilder.js'; export type { VersionedImageBuildResult } from './dockerImageBuilder.js'; diff --git a/packages/core/src/claude/docker/dockerImageBuilder.ts b/packages/core/src/claude/docker/dockerImageBuilder.ts index f2efaa74d..e384ab9b1 100644 --- a/packages/core/src/claude/docker/dockerImageBuilder.ts +++ b/packages/core/src/claude/docker/dockerImageBuilder.ts @@ -15,6 +15,7 @@ const PROJECT_ROOT = process.env.PROPR_ROOT || (fs.existsSync(path.join(process.cwd(), 'Dockerfile.agent')) ? process.cwd() : '/usr/src/app'); const AGENT_DOCKERFILE = 'Dockerfile.agent'; const SAFE_BUILD_VERSION = /^[0-9A-Za-z][0-9A-Za-z.!+_-]*$/; +const pendingImagePreparations = new Map>(); export interface VersionedImageBuildResult { success: boolean; @@ -48,7 +49,7 @@ function bundleBuildArgs(versions: AgentCliVersionMatrix): string[] { ]; } -async function imageExists(image: string): Promise { +export async function agentDockerImageExists(image: string): Promise { const result = await executeDockerCommand('docker', ['images', '-q', image]); return result.exitCode === 0 && Boolean(result.stdout.trim()); } @@ -120,16 +121,16 @@ function scheduleBundleImageCleanup(imageTag: string): void { }); } -export async function ensureAgentBundleImage( +async function prepareAgentBundleImage( versions: AgentCliVersionMatrix, contentHash: string, - basePath: string = PROJECT_ROOT + basePath: string, + imageTag: string, ): Promise { - const imageTag = generateAgentBundleImageTag(versions, contentHash); logger.info({ imageTag, versions, contentHash }, 'Ensuring unified agent Docker image exists...'); try { - if (await imageExists(imageTag)) return { success: true, imageTag }; + if (await agentDockerImageExists(imageTag)) return { success: true, imageTag }; if (await pullImage(imageTag)) return { success: true, imageTag }; const built = await buildBundle(imageTag, versions, basePath); if (built.success) scheduleBundleImageCleanup(imageTag); @@ -141,18 +142,48 @@ export async function ensureAgentBundleImage( } } +/** + * Pulls or builds one bundle tag at most once per process at a time. Registry + * refreshes can arrive concurrently (HTTP requests, config notifications, and + * startup), but they must all await the same Docker operation. + */ +export function ensureAgentBundleImage( + versions: AgentCliVersionMatrix, + contentHash: string, + basePath: string = PROJECT_ROOT +): Promise { + const imageTag = generateAgentBundleImageTag(versions, contentHash); + const pending = pendingImagePreparations.get(imageTag); + if (pending) return pending; + + const preparation = prepareAgentBundleImage(versions, contentHash, basePath, imageTag) + .finally(() => { + pendingImagePreparations.delete(imageTag); + }); + pendingImagePreparations.set(imageTag, preparation); + return preparation; +} + /** Ensures a directly configured image such as propr/agent:latest is available. */ export async function ensureAgentDockerImage(_agentType: string, dockerImage: string): Promise { - try { - if (await imageExists(dockerImage)) return true; - if (await pullImage(dockerImage)) return true; - const versions = getDefaultAgentCliVersionMatrix(); - const built = await buildBundle(dockerImage, versions, PROJECT_ROOT); - return built.success; - } catch (error) { - logger.error({ dockerImage, error: (error as Error).message }, 'Error ensuring agent Docker image'); - return false; - } + const pending = pendingImagePreparations.get(dockerImage); + if (pending) return (await pending).success; + + const preparation = (async (): Promise => { + try { + if (await agentDockerImageExists(dockerImage)) return { success: true, imageTag: dockerImage }; + if (await pullImage(dockerImage)) return { success: true, imageTag: dockerImage }; + const versions = getDefaultAgentCliVersionMatrix(); + return buildBundle(dockerImage, versions, PROJECT_ROOT); + } catch (error) { + logger.error({ dockerImage, error: (error as Error).message }, 'Error ensuring agent Docker image'); + return { success: false, imageTag: dockerImage, error: (error as Error).message }; + } + })().finally(() => { + pendingImagePreparations.delete(dockerImage); + }); + pendingImagePreparations.set(dockerImage, preparation); + return (await preparation).success; } export async function buildClaudeDockerImage(): Promise { diff --git a/packages/core/src/claude/prompts/promptGenerator.ts b/packages/core/src/claude/prompts/promptGenerator.ts index 3666ffa53..e79ad4e00 100644 --- a/packages/core/src/claude/prompts/promptGenerator.ts +++ b/packages/core/src/claude/prompts/promptGenerator.ts @@ -1,3 +1,6 @@ +import { buildVisualPreviewPrompt } from '../../services/visualPreviewService.js'; +import type { VisualPreviewSettings } from '../../config/configManager.js'; + export interface IssueLabel { name: string; } @@ -57,6 +60,7 @@ export interface GenerateClaudePromptOptions { modelName?: string | null; issueDetails?: IssueDetails | null; baseBranch?: string | null; + visualPreviewSettings?: VisualPreviewSettings; } function buildIssueDetailsSection(issueRef: IssueRef, issueDetails: IssueDetails): string { @@ -92,12 +96,13 @@ function buildCommentsSection(comments: IssueComment[] | undefined): string { } export function generateClaudePrompt(options: GenerateClaudePromptOptions): string { - const { issueRef, branchName = null, modelName = null, issueDetails = null, baseBranch = null } = options; + const { issueRef, branchName = null, modelName = null, issueDetails = null, baseBranch = null, visualPreviewSettings } = options; const branchInfo = branchName ? `\n- **BRANCH**: You are working on branch \`${branchName}\`.` : ''; const baseBranchInfo = baseBranch ? `\n- **BASE BRANCH**: \`${baseBranch}\` (PRs must target this branch, not main)` : ''; const modelInfo = modelName ? `\n- **MODEL**: This task is being processed by the \`${modelName}\` model.` : ''; const issueDetailsSection = issueDetails ? buildIssueDetailsSection(issueRef, issueDetails) : ''; + const visualPreviewInstructions = visualPreviewSettings ? buildVisualPreviewPrompt(visualPreviewSettings) : ''; return `Please analyze and implement a solution for GitHub issue #${issueRef.number}. @@ -119,6 +124,7 @@ Follow these steps systematically: 6. Implement the necessary changes to solve the issue 7. Test your implementation (if applicable and possible) 8. Ensure code follows existing patterns and conventions +${visualPreviewInstructions} **IMPORTANT NOTES:** - **DO NOT** worry about git operations (add, commit, push, PR creation) diff --git a/packages/core/src/config/configManager.ts b/packages/core/src/config/configManager.ts index d246724fb..cf7fe45a9 100644 --- a/packages/core/src/config/configManager.ts +++ b/packages/core/src/config/configManager.ts @@ -18,11 +18,40 @@ export interface RepoToMonitor { name: string; // owner/repo enabled: boolean; autoFollowupOnFailedCi?: boolean; // Defaults to false for legacy configurations + visualPreview?: VisualPreviewSettings; // Defaults to disabled for legacy configurations alias?: string; // Optional display name baseBranch?: string; // Optional specific branch to monitor defaultBranch?: string; // Optional repository default branch for demo metadata } +export type VisualPreviewType = 'image' | 'video'; + +export interface VisualPreviewSettings { + enabled: boolean; + types: VisualPreviewType[]; + instructions?: string; +} + +export function normalizeStoredVisualPreviewSettings(value: unknown): VisualPreviewSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { enabled: false, types: ['image'] }; + } + + const candidate = value as Partial; + const types = Array.isArray(candidate.types) + ? [...new Set(candidate.types.filter((type): type is VisualPreviewType => type === 'image' || type === 'video'))] + : []; + const instructions = typeof candidate.instructions === 'string' && candidate.instructions.trim() + ? candidate.instructions.trim() + : undefined; + + return { + enabled: candidate.enabled === true, + types: types.length > 0 ? types : ['image'], + ...(instructions ? { instructions } : {}) + }; +} + interface ConfigSettings { worker_concurrency?: number; analysis_model_fast?: string; @@ -120,6 +149,35 @@ export async function loadMonitoredReposRaw(): Promise { return rawRepos; } +/** + * Resolve the branch-independent visual-preview policy for a repository. + * Multiple branch entries may exist for one repository; an explicitly enabled + * entry wins over disabled or legacy entries until the next synchronized save. + */ +export function resolveRepositoryVisualPreviewSettings( + repos: readonly RepoToMonitor[], + repository: string +): VisualPreviewSettings { + const normalizedRepository = repository.trim().toLowerCase(); + if (!normalizedRepository) return { enabled: false, types: ['image'] }; + + const matching = repos.filter(repo => repo.name.trim().toLowerCase() === normalizedRepository); + const configured = matching.find(repo => normalizeStoredVisualPreviewSettings(repo.visualPreview).enabled) + ?? matching.find(repo => repo.visualPreview !== undefined); + return normalizeStoredVisualPreviewSettings(configured?.visualPreview); +} + +export async function loadRepositoryVisualPreviewSettings(repository: string): Promise { + try { + const settings = resolveRepositoryVisualPreviewSettings(await loadMonitoredReposRaw(), repository); + logger.info({ repository, enabled: settings.enabled, types: settings.types }, 'Loaded repository visual-preview settings'); + return settings; + } catch (error) { + logger.warn({ repository, error: (error as Error).message }, 'Failed to load visual-preview settings; treating previews as disabled'); + return { enabled: false, types: ['image'] }; + } +} + export async function saveMonitoredRepos(repos: RepoToMonitor[], client?: Knex | Knex.Transaction): Promise { await saveConfig('repos_to_monitor', repos, client); logger.info({ repos }, 'Successfully saved monitored repositories'); diff --git a/packages/core/src/db/migrations/20260903000000_create_visual_preview_oauth_credentials.js b/packages/core/src/db/migrations/20260903000000_create_visual_preview_oauth_credentials.js new file mode 100644 index 000000000..a521c0625 --- /dev/null +++ b/packages/core/src/db/migrations/20260903000000_create_visual_preview_oauth_credentials.js @@ -0,0 +1,27 @@ +/** + * Stores the single GitHub user credential used for visual-preview uploads. + * Token material is encrypted by the application before it reaches SQLite. + */ +export async function up(knex) { + await knex.schema.createTable('visual_preview_oauth_credentials', table => { + table.integer('id').primary(); + table.string('github_user_id', 255).notNullable(); + table.string('github_username', 255).notNullable(); + table.string('source', 32).notNullable(); + table.text('access_token_encrypted').notNullable(); + table.text('refresh_token_encrypted').nullable(); + table.bigInteger('access_token_expires_at_ms').nullable(); + table.bigInteger('refresh_token_expires_at_ms').nullable(); + table.string('status', 32).notNullable().defaultTo('active'); + table.string('last_error_code', 64).nullable(); + table.bigInteger('refresh_lease_until_ms').nullable(); + table.string('refresh_lease_owner', 64).nullable(); + table.timestamp('last_refreshed_at').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable(); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('visual_preview_oauth_credentials'); +} diff --git a/packages/core/src/git/commitOperations.ts b/packages/core/src/git/commitOperations.ts index 5e12cf281..0f58fce2a 100644 --- a/packages/core/src/git/commitOperations.ts +++ b/packages/core/src/git/commitOperations.ts @@ -119,7 +119,7 @@ export async function commitChanges(worktreePath: string, commitMessage: string await git.add('.'); // Unstage generated ProPR runtime directories. Repo-authored files such // as .propr/setup.sh and .propr/package.json should remain committable. - for (const generatedPath of ['.propr/assets', '.propr/cache', '.propr/.cache', '.propr/node_modules']) { + for (const generatedPath of ['.propr/assets', '.propr/cache', '.propr/.cache', '.propr/node_modules', '.propr/previews']) { try { await git.raw(['reset', 'HEAD', '--', generatedPath]); } catch { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7846d92c0..35e9bb9e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- public package exports are intentionally centralized */ export { default as logger, generateCorrelationId, createCorrelatedLogger } from './utils/logger.js'; export { handleError, withErrorHandling, safeAsync, makeIdempotent, categorizeError, ErrorCategories } from './utils/errorHandler.js'; export type { ErrorCategory, ErrorDetails, ErrorHandlerOptions, IssueRef as ErrorIssueRef } from './utils/errorHandler.js'; @@ -174,6 +175,8 @@ export type { IssueLink, ExecutionResult, EpicPRResult, EnsureEpicPROptions } fr export { validateAttachmentBaseUrlConfig } from './services/taskExecutionHelpers.js'; export { AttachmentService } from './services/attachmentService.js'; export type { Attachment, MulterFile } from './services/attachmentService.js'; +export * from './services/visualPreviewService.js'; +export * from './services/visualPreviewOAuthCredentialService.js'; export { PLANNER_SYSTEM_PROMPT, GRANULARITY_INSTRUCTIONS, getPlannerPrompt, REFINER_SYSTEM_PROMPT } from './claude/prompts/plannerPrompts.js'; export type { Plan, PlanItem, RefinementResponse } from './claude/prompts/plannerPrompts.js'; export { parseLlmJson, JsonParseError } from './utils/jsonUtils.js'; diff --git a/packages/core/src/services/visualPreviewOAuthCredentialService.ts b/packages/core/src/services/visualPreviewOAuthCredentialService.ts new file mode 100644 index 000000000..dfe6cdb45 --- /dev/null +++ b/packages/core/src/services/visualPreviewOAuthCredentialService.ts @@ -0,0 +1,475 @@ +/* eslint-disable max-lines -- storage, encryption, leasing, and provider refresh form one credential boundary */ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; +import type { Knex } from 'knex'; +import { db } from '../db/connection.js'; + +const CREDENTIAL_ID = 1; +const ACCESS_TOKEN_REFRESH_BUFFER_MS = 60 * 60 * 1000; +const TOKEN_REFRESH_TIMEOUT_MS = 20_000; +const REFRESH_LEASE_MS = TOKEN_REFRESH_TIMEOUT_MS + 5_000; +const REFRESH_LEASE_POLL_MS = 100; +const ENCRYPTION_CONTEXT = 'propr:visual-preview-oauth:v1'; +// GitHub CLI's attachment uploader accepts OAuth App and personal-access +// tokens. It deliberately rejects both GitHub App user (`ghu_`) and +// installation (`ghs_`) tokens before making an upload request. +const SUPPORTED_TOKEN_PATTERN = /^(?:gho_|ghp_|github_pat_)/; + +export const VISUAL_PREVIEW_UPLOAD_TOKEN_ENV = 'GITHUB_VISUAL_PREVIEW_TOKEN'; +export const VISUAL_PREVIEW_CREDENTIAL_KEY_ENV = 'PROPR_CREDENTIAL_ENCRYPTION_KEY'; + +export type VisualPreviewOAuthSource = 'github' | 'connect' | 'static_token'; +export type VisualPreviewOAuthStatus = 'active' | 'reauth_required'; + +export interface VisualPreviewOAuthCredentialInput { + githubUserId: string; + githubUsername: string; + source: VisualPreviewOAuthSource; + accessToken: string; + refreshToken?: string; + accessTokenExpiresAt?: number; + refreshTokenExpiresAt?: number; +} + +export interface VisualPreviewOAuthCredentialStatus { + configured: boolean; + source?: VisualPreviewOAuthSource | 'environment'; + status: VisualPreviewOAuthStatus | 'missing'; + githubUsername?: string; + accessTokenExpiresAt?: number; + refreshTokenExpiresAt?: number; + lastErrorCode?: string; + updatedAt?: string; +} + +export interface VisualPreviewOAuthCredentialGrant { + status: 'active' | 'reauth_required'; + accessToken?: string; + refreshToken?: string; + accessTokenExpiresAt?: number; + refreshTokenExpiresAt?: number; +} + +interface CredentialRow { + id: number; + github_user_id: string; + github_username: string; + source: VisualPreviewOAuthSource; + access_token_encrypted: string; + refresh_token_encrypted: string | null; + access_token_expires_at_ms: number | string | null; + refresh_token_expires_at_ms: number | string | null; + status: VisualPreviewOAuthStatus; + last_error_code: string | null; + refresh_lease_until_ms: number | string | null; + refresh_lease_owner: string | null; + last_refreshed_at: string | null; + created_at: string; + updated_at: string; +} + +interface TokenRefreshResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + refresh_token_expires_in?: number; + error?: string; + error_description?: string; +} + +export type VisualPreviewCredentialErrorCode = + | 'VISUAL_PREVIEW_AUTH_MISSING' + | 'VISUAL_PREVIEW_AUTH_UNSUPPORTED' + | 'VISUAL_PREVIEW_AUTH_EXPIRED' + | 'VISUAL_PREVIEW_AUTH_REAUTH_REQUIRED' + | 'VISUAL_PREVIEW_AUTH_DECRYPTION_FAILED'; + +export class VisualPreviewCredentialError extends Error { + constructor( + public readonly code: VisualPreviewCredentialErrorCode, + message: string, + ) { + super(message); + this.name = 'VisualPreviewCredentialError'; + } +} + +export function isVisualPreviewCredentialError(error: unknown): error is VisualPreviewCredentialError { + return error instanceof VisualPreviewCredentialError || ( + error instanceof Error + && 'code' in error + && typeof (error as { code?: unknown }).code === 'string' + && (error as { code: string }).code.startsWith('VISUAL_PREVIEW_AUTH_') + ); +} + +export function isSupportedVisualPreviewUploadToken(token: string): boolean { + return SUPPORTED_TOKEN_PATTERN.test(token.trim()); +} + +function optionalTimestamp(value: number | string | null): number | undefined { + if (value === null) return undefined; + const timestamp = Number(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function encryptionSecret(environment: NodeJS.ProcessEnv): string | undefined { + return environment[VISUAL_PREVIEW_CREDENTIAL_KEY_ENV]?.trim() + || environment.SYSTEM_TASK_SECRET?.trim() + || environment.SESSION_SECRET?.trim(); +} + +function encryptionKey(environment: NodeJS.ProcessEnv): Buffer { + const secret = encryptionSecret(environment); + if (!secret) { + throw new Error( + `${VISUAL_PREVIEW_CREDENTIAL_KEY_ENV}, SYSTEM_TASK_SECRET, or SESSION_SECRET must be configured ` + + 'to store the visual-preview OAuth credential securely.', + ); + } + return createHash('sha256').update(ENCRYPTION_CONTEXT).update('\0').update(secret).digest(); +} + +function encryptToken(token: string, environment: NodeJS.ProcessEnv): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', encryptionKey(environment), iv); + const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return ['v1', iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join('.'); +} + +function decryptToken(value: string, environment: NodeJS.ProcessEnv): string { + try { + const [version, encodedIv, encodedTag, encodedCiphertext] = value.split('.'); + if (version !== 'v1' || !encodedIv || !encodedTag || !encodedCiphertext) throw new Error('invalid envelope'); + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(environment), Buffer.from(encodedIv, 'base64url')); + decipher.setAuthTag(Buffer.from(encodedTag, 'base64url')); + return Buffer.concat([ + decipher.update(Buffer.from(encodedCiphertext, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } catch { + throw new VisualPreviewCredentialError( + 'VISUAL_PREVIEW_AUTH_DECRYPTION_FAILED', + 'The stored visual-preview OAuth credential could not be decrypted. Verify the shared credential encryption secret.', + ); + } +} + +function assertSupportedToken(token: string): string { + const normalized = token.trim(); + if (!isSupportedVisualPreviewUploadToken(normalized)) { + throw new VisualPreviewCredentialError( + 'VISUAL_PREVIEW_AUTH_UNSUPPORTED', + 'GitHub visual-preview uploads require an OAuth App token or personal access token; GitHub App user and installation tokens are not supported.', + ); + } + return normalized; +} + +function resolveEnvironmentToken(environment: NodeJS.ProcessEnv): string | undefined { + const token = environment[VISUAL_PREVIEW_UPLOAD_TOKEN_ENV]?.trim(); + return token ? assertSupportedToken(token) : undefined; +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function statusFromRow(row: CredentialRow): VisualPreviewOAuthCredentialStatus { + return { + configured: true, + source: row.source, + status: row.status, + githubUsername: row.github_username, + accessTokenExpiresAt: optionalTimestamp(row.access_token_expires_at_ms), + refreshTokenExpiresAt: optionalTimestamp(row.refresh_token_expires_at_ms), + lastErrorCode: row.last_error_code || undefined, + updatedAt: row.updated_at, + }; +} + +function isUnrecoverableRefreshError(error?: string): boolean { + return error === 'bad_refresh_token' || error === 'invalid_grant'; +} + +export class VisualPreviewOAuthCredentialService { + constructor( + private readonly database: Knex = db, + private readonly environment: NodeJS.ProcessEnv = process.env, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + private credentialQuery() { + return this.database('visual_preview_oauth_credentials').where({ id: CREDENTIAL_ID }); + } + + async getStatus(): Promise { + const rawEnvironmentToken = this.environment[VISUAL_PREVIEW_UPLOAD_TOKEN_ENV]?.trim(); + if (rawEnvironmentToken) { + return isSupportedVisualPreviewUploadToken(rawEnvironmentToken) + ? { configured: true, source: 'environment', status: 'active' } + : { + configured: true, + source: 'environment', + status: 'reauth_required', + lastErrorCode: 'unsupported_environment_token', + }; + } + const row = await this.credentialQuery().first(); + return row ? statusFromRow(row) : { configured: false, status: 'missing' }; + } + + async captureFromLogin(input: VisualPreviewOAuthCredentialInput): Promise { + assertSupportedToken(input.accessToken); + const existing = await this.credentialQuery().first(); + if (existing && existing.github_user_id !== input.githubUserId && existing.status === 'active') return false; + await this.store(input); + return true; + } + + async replace(input: VisualPreviewOAuthCredentialInput): Promise { + assertSupportedToken(input.accessToken); + await this.store(input); + } + + async updateIfOwner(input: VisualPreviewOAuthCredentialInput): Promise { + assertSupportedToken(input.accessToken); + const existing = await this.credentialQuery().first(); + if (!existing || existing.github_user_id !== input.githubUserId) return false; + await this.store(input); + return true; + } + + private async store(input: VisualPreviewOAuthCredentialInput): Promise { + const now = this.database.fn.now(); + const values = { + id: CREDENTIAL_ID, + github_user_id: input.githubUserId, + github_username: input.githubUsername, + source: input.source, + access_token_encrypted: encryptToken(input.accessToken.trim(), this.environment), + refresh_token_encrypted: input.refreshToken + ? encryptToken(input.refreshToken.trim(), this.environment) + : null, + access_token_expires_at_ms: input.accessTokenExpiresAt ?? null, + refresh_token_expires_at_ms: input.refreshTokenExpiresAt ?? null, + status: 'active' as const, + last_error_code: null, + refresh_lease_until_ms: null, + refresh_lease_owner: null, + updated_at: now, + }; + await this.database('visual_preview_oauth_credentials') + .insert({ ...values, created_at: now }) + .onConflict('id') + .merge(values); + } + + async disconnect(): Promise { + await this.credentialQuery().delete(); + } + + async resolveUploadToken(): Promise { + const environmentToken = resolveEnvironmentToken(this.environment); + if (environmentToken) return environmentToken; + + const row = await this.credentialQuery().first(); + if (!row) { + throw new VisualPreviewCredentialError( + 'VISUAL_PREVIEW_AUTH_MISSING', + 'No GitHub user credential is configured for visual-preview uploads.', + ); + } + if (row.status !== 'active') { + throw new VisualPreviewCredentialError( + 'VISUAL_PREVIEW_AUTH_REAUTH_REQUIRED', + 'The GitHub user credential for visual-preview uploads must be reconnected.', + ); + } + const expiresAt = optionalTimestamp(row.access_token_expires_at_ms); + if (expiresAt !== undefined && expiresAt <= Date.now()) { + throw new VisualPreviewCredentialError( + 'VISUAL_PREVIEW_AUTH_EXPIRED', + 'The GitHub user credential for visual-preview uploads has expired.', + ); + } + return assertSupportedToken(decryptToken(row.access_token_encrypted, this.environment)); + } + + async markReauthRequired(errorCode: string): Promise { + if (this.environment[VISUAL_PREVIEW_UPLOAD_TOKEN_ENV]?.trim()) return; + await this.credentialQuery().update({ + status: 'reauth_required', + last_error_code: errorCode.slice(0, 64), + refresh_lease_until_ms: null, + refresh_lease_owner: null, + updated_at: this.database.fn.now(), + }); + } + + async refreshIfNeeded(force = false): Promise<'missing' | 'not-needed' | 'refreshed' | 'reauth-required'> { + if (resolveEnvironmentToken(this.environment)) return 'not-needed'; + const row = await this.credentialQuery().first(); + if (!row) return 'missing'; + if (row.status !== 'active') return 'reauth-required'; + + const expiresAt = optionalTimestamp(row.access_token_expires_at_ms); + const needsRefresh = force || (expiresAt !== undefined && expiresAt - Date.now() < ACCESS_TOKEN_REFRESH_BUFFER_MS); + if (!needsRefresh) return 'not-needed'; + if (!row.refresh_token_encrypted) { + await this.markReauthRequired('missing_refresh_token'); + return 'reauth-required'; + } + + const leaseOwner = randomBytes(16).toString('hex'); + const leaseAcquired = await this.database('visual_preview_oauth_credentials') + .where({ id: CREDENTIAL_ID, status: 'active' }) + .andWhere(builder => builder + .whereNull('refresh_lease_until_ms') + .orWhere('refresh_lease_until_ms', '<', Date.now())) + .update({ + refresh_lease_owner: leaseOwner, + refresh_lease_until_ms: Date.now() + REFRESH_LEASE_MS, + }); + if (leaseAcquired === 0) { + await this.waitForRefreshLease(); + const refreshedRow = await this.credentialQuery().first(); + if (!refreshedRow) return 'missing'; + if (refreshedRow.status !== 'active') return 'reauth-required'; + const refreshedExpiry = optionalTimestamp(refreshedRow.access_token_expires_at_ms); + if (refreshedExpiry !== undefined && refreshedExpiry - Date.now() < ACCESS_TOKEN_REFRESH_BUFFER_MS) { + throw new Error('Concurrent GitHub OAuth refresh did not produce a usable access token'); + } + return 'not-needed'; + } + + try { + const refreshToken = decryptToken(row.refresh_token_encrypted, this.environment); + const response = await this.requestRefresh(row.source, refreshToken); + if (response.error) { + if (isUnrecoverableRefreshError(response.error)) { + await this.markReauthRequired(response.error); + return 'reauth-required'; + } + throw new Error(`GitHub OAuth refresh was temporarily unavailable (${response.error})`); + } + if (!response.access_token) throw new Error('GitHub OAuth refresh response did not include an access token'); + + const now = Date.now(); + await this.store({ + githubUserId: row.github_user_id, + githubUsername: row.github_username, + source: row.source, + accessToken: response.access_token, + refreshToken: response.refresh_token || refreshToken, + accessTokenExpiresAt: response.expires_in ? now + response.expires_in * 1000 : undefined, + refreshTokenExpiresAt: response.refresh_token_expires_in + ? now + response.refresh_token_expires_in * 1000 + : optionalTimestamp(row.refresh_token_expires_at_ms), + }); + await this.credentialQuery().update({ last_refreshed_at: this.database.fn.now() }); + return 'refreshed'; + } finally { + await this.database('visual_preview_oauth_credentials') + .where({ id: CREDENTIAL_ID, refresh_lease_owner: leaseOwner }) + .update({ refresh_lease_owner: null, refresh_lease_until_ms: null }); + } + } + + async refreshAndGetForOwner( + githubUserId: string, + force = false, + ): Promise { + const current = await this.credentialQuery().first(); + if (!current || current.github_user_id !== githubUserId) return null; + // A manually supplied or CLI-imported token is dedicated to background + // preview uploads. Never copy it into the administrator's browser session + // or try to refresh it as an OAuth grant. + if (current.source === 'static_token') return null; + const refreshStatus = await this.refreshIfNeeded(force); + if (refreshStatus === 'reauth-required') return { status: 'reauth_required' }; + const row = await this.credentialQuery().first(); + if (!row || row.github_user_id !== githubUserId) return null; + if (row.status !== 'active') return { status: 'reauth_required' }; + return { + status: 'active', + accessToken: decryptToken(row.access_token_encrypted, this.environment), + refreshToken: row.refresh_token_encrypted + ? decryptToken(row.refresh_token_encrypted, this.environment) + : undefined, + accessTokenExpiresAt: optionalTimestamp(row.access_token_expires_at_ms), + refreshTokenExpiresAt: optionalTimestamp(row.refresh_token_expires_at_ms), + }; + } + + private async waitForRefreshLease(): Promise { + const deadline = Date.now() + REFRESH_LEASE_MS; + while (Date.now() < deadline) { + const row = await this.credentialQuery().first(); + if (!row?.refresh_lease_owner || (optionalTimestamp(row.refresh_lease_until_ms) || 0) <= Date.now()) return; + await delay(REFRESH_LEASE_POLL_MS); + } + } + + private async requestRefresh(source: VisualPreviewOAuthSource, refreshToken: string): Promise { + if (source === 'connect') return this.requestConnectRefresh(refreshToken); + const clientId = this.environment.GH_OAUTH_CLIENT_ID?.trim(); + const clientSecret = this.environment.GH_OAUTH_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) throw new Error('GitHub OAuth client credentials are unavailable for token refresh'); + return this.postRefresh('https://github.com/login/oauth/access_token', { + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + } + + private async requestConnectRefresh(refreshToken: string): Promise { + const relayUrl = this.environment.PROPR_GH_RELAY_URL?.trim().replace(/\/+$/, ''); + const relayToken = this.environment.PROPR_GH_RELAY_TOKEN?.trim(); + if (!relayUrl || !relayToken) throw new Error('ProPR Connect credentials are unavailable for token refresh'); + const endpoint = new URL(`${relayUrl}/auth/instance-grants/refresh`); + if (endpoint.protocol !== 'https:' && endpoint.hostname !== 'localhost' && endpoint.hostname !== '127.0.0.1') { + throw new Error('PROPR_GH_RELAY_URL must use HTTPS'); + } + return this.postRefresh(endpoint, { refresh_token: refreshToken }, relayToken); + } + + private async postRefresh( + endpoint: string | URL, + body: Record, + bearerToken?: string, + ): Promise { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`GitHub OAuth refresh failed with HTTP ${response.status}`); + return response.json() as Promise; + } +} + +const defaultService = new VisualPreviewOAuthCredentialService(); + +export function resolveVisualPreviewUploadToken(): Promise { + return defaultService.resolveUploadToken(); +} + +export function refreshVisualPreviewOAuthCredential(force = false) { + return defaultService.refreshIfNeeded(force); +} + +export function markVisualPreviewOAuthCredentialReauthRequired(errorCode: string): Promise { + return defaultService.markReauthRequired(errorCode); +} diff --git a/packages/core/src/services/visualPreviewService.ts b/packages/core/src/services/visualPreviewService.ts new file mode 100644 index 000000000..32a1c9f56 --- /dev/null +++ b/packages/core/src/services/visualPreviewService.ts @@ -0,0 +1,406 @@ +import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { VisualPreviewSettings, VisualPreviewType } from '../config/configManager.js'; +import { createHooklessGit } from '../git/hooklessGit.js'; + +export const VISUAL_PREVIEW_DIRECTORY = '.propr/previews'; +export const VISUAL_PREVIEW_MANIFEST = `${VISUAL_PREVIEW_DIRECTORY}/manifest.json`; +export const VISUAL_PREVIEW_MARKER = ''; +export const VISUAL_PREVIEW_SLOT = ''; + +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_GITHUB_ATTACHMENT_BYTES = 10 * 1024 * 1024; +const MAX_PREVIEW_ASSETS = 8; +const IMAGE_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']); +const VIDEO_EXTENSIONS = new Set(['.mov', '.mp4', '.webm']); + +export interface VisualPreviewAsset { + relativePath: string; + absolutePath: string; + type: VisualPreviewType; + title: string; + description?: string; +} + +export interface VisualPreviewToolSuggestion { + name: string; + reason: string; +} + +export interface VisualPreviewEvidence { + assets: VisualPreviewAsset[]; + toolSuggestions: VisualPreviewToolSuggestion[]; +} + +interface VisualPreviewManifestEntry { + path?: unknown; + title?: unknown; + description?: unknown; +} + +interface VisualPreviewManifestData { + previews?: unknown; + toolSuggestions?: unknown; +} + +export interface CollectVisualPreviewEvidenceOptions { + worktreePath: string; + changedFiles: readonly string[]; + settings: VisualPreviewSettings; +} + +export interface RenderVisualPreviewOptions { + useLocalPaths?: boolean; +} + +export interface PrepareVisualPreviewEvidenceOptions { + worktreePath: string; + settings: VisualPreviewSettings; + taskId: string; + changedFiles?: readonly string[]; +} + +export interface PreparedVisualPreviewEvidence { + evidence: VisualPreviewEvidence; + temporaryDirectory?: string; +} + +function previewTypeForPath(filePath: string): VisualPreviewType | null { + const extension = path.posix.extname(filePath).toLowerCase(); + if (IMAGE_EXTENSIONS.has(extension)) return 'image'; + if (VIDEO_EXTENSIONS.has(extension)) return 'video'; + return null; +} + +function normalizeRepositoryPath(filePath: string): string | null { + const normalized = path.posix.normalize(filePath.replaceAll('\\', '/')).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) { + return null; + } + return normalized; +} + +function normalizeManifestPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + const candidate = value.trim().replaceAll('\\', '/'); + return normalizeRepositoryPath(candidate.startsWith(`${VISUAL_PREVIEW_DIRECTORY}/`) + ? candidate + : `${VISUAL_PREVIEW_DIRECTORY}/${candidate}`); +} + +function plainText(value: unknown, maximumLength: number): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim(); + return normalized ? normalized.slice(0, maximumLength) : undefined; +} + +function inferredTitle(filePath: string): string { + const stem = path.posix.basename(filePath, path.posix.extname(filePath)); + const title = stem.replace(/[-_]+/g, ' ').replace(/\s{2,}/g, ' ').trim(); + return title ? title.replace(/^./, character => character.toUpperCase()) : 'Visual preview'; +} + +async function readManifest(worktreePath: string, changedFiles: Set): Promise { + if (!changedFiles.has(VISUAL_PREVIEW_MANIFEST)) return null; + const manifestPath = path.resolve(worktreePath, VISUAL_PREVIEW_MANIFEST); + try { + const stats = await lstat(manifestPath); + if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_MANIFEST_BYTES) return null; + const [realRoot, realManifest] = await Promise.all([realpath(worktreePath), realpath(manifestPath)]); + if (realManifest !== realRoot && !realManifest.startsWith(`${realRoot}${path.sep}`)) return null; + const parsed = JSON.parse(await readFile(manifestPath, 'utf8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as VisualPreviewManifestData + : null; + } catch { + return null; + } +} + +function manifestEntriesByPath(manifest: VisualPreviewManifestData | null): Map { + const entries = new Map(); + if (!Array.isArray(manifest?.previews)) return entries; + for (const value of manifest.previews) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const entry = value as VisualPreviewManifestEntry; + const normalizedPath = normalizeManifestPath(entry.path); + if (normalizedPath) entries.set(normalizedPath, entry); + } + return entries; +} + +function manifestToolSuggestions(manifest: VisualPreviewManifestData | null): VisualPreviewToolSuggestion[] { + if (!Array.isArray(manifest?.toolSuggestions)) return []; + return manifest.toolSuggestions.flatMap(value => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + const candidate = value as { name?: unknown; reason?: unknown }; + const name = plainText(candidate.name, 80); + const reason = plainText(candidate.reason, 300); + return name && reason ? [{ name, reason }] : []; + }).slice(0, 5); +} + +async function collectAsset( + worktreePath: string, + relativePath: string, + type: VisualPreviewType, + manifestEntry: VisualPreviewManifestEntry | undefined +): Promise<{ asset?: VisualPreviewAsset; oversized?: boolean }> { + const absolutePath = path.resolve(worktreePath, relativePath); + const root = path.resolve(worktreePath); + if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) return {}; + + try { + const stats = await lstat(absolutePath); + if (!stats.isFile() || stats.isSymbolicLink()) return {}; + const [realRoot, realAsset] = await Promise.all([realpath(root), realpath(absolutePath)]); + if (realAsset !== realRoot && !realAsset.startsWith(`${realRoot}${path.sep}`)) return {}; + if (stats.size === 0) return {}; + if (stats.size > MAX_GITHUB_ATTACHMENT_BYTES) return { oversized: true }; + } catch { + return {}; + } + + return { + asset: { + relativePath, + absolutePath, + type, + title: plainText(manifestEntry?.title, 120) || inferredTitle(relativePath), + ...(plainText(manifestEntry?.description, 300) + ? { description: plainText(manifestEntry?.description, 300) } + : {}) + } + }; +} + +export async function collectVisualPreviewEvidence({ + worktreePath, + changedFiles, + settings +}: CollectVisualPreviewEvidenceOptions): Promise { + if (!settings.enabled) return { assets: [], toolSuggestions: [] }; + + const normalizedChangedFiles = new Set(changedFiles + .map(normalizeRepositoryPath) + .filter((filePath): filePath is string => Boolean(filePath))); + const manifest = await readManifest(worktreePath, normalizedChangedFiles); + const manifestEntries = manifestEntriesByPath(manifest); + const toolSuggestions = manifestToolSuggestions(manifest); + const candidates = [...normalizedChangedFiles] + .filter(filePath => filePath.startsWith(`${VISUAL_PREVIEW_DIRECTORY}/`)) + .map(filePath => ({ filePath, type: previewTypeForPath(filePath) })) + .filter((candidate): candidate is { filePath: string; type: VisualPreviewType } => candidate.type !== null) + .filter(candidate => settings.types.includes(candidate.type)) + .sort((left, right) => left.filePath.localeCompare(right.filePath)) + .slice(0, MAX_PREVIEW_ASSETS); + + const assets: VisualPreviewAsset[] = []; + let oversized = false; + for (const candidate of candidates) { + const collected = await collectAsset(worktreePath, candidate.filePath, candidate.type, manifestEntries.get(candidate.filePath)); + if (collected.asset) assets.push(collected.asset); + oversized ||= collected.oversized === true; + } + + if (oversized) { + toolSuggestions.push({ + name: 'Media compression tooling', + reason: 'At least one generated preview exceeded GitHub’s universal 10 MB attachment limit; install or use an image optimizer or ffmpeg to shrink it.' + }); + } + + return { assets, toolSuggestions: toolSuggestions.slice(0, 5) }; +} + +function safeTemporaryName(taskId: string): string { + const sanitized = taskId.replace(/[^a-zA-Z0-9_-]+/g, '-'); + let start = 0; + let end = sanitized.length; + while (sanitized[start] === '-') start += 1; + while (end > start && sanitized[end - 1] === '-') end -= 1; + const normalized = sanitized.slice(start, Math.min(end, start + 80)); + return normalized || 'task'; +} + +async function copyEvidenceToTemporaryDirectory( + evidence: VisualPreviewEvidence, + taskId: string +): Promise { + if (evidence.assets.length === 0) return { evidence }; + + const temporaryRoot = path.join(tmpdir(), 'propr-previews'); + await mkdir(temporaryRoot, { recursive: true }); + const temporaryDirectory = await mkdtemp(path.join(temporaryRoot, `${safeTemporaryName(taskId)}-`)); + + try { + const assets: VisualPreviewAsset[] = []; + for (const asset of evidence.assets) { + const previewRelativePath = asset.relativePath.slice(`${VISUAL_PREVIEW_DIRECTORY}/`.length); + const destination = path.resolve(temporaryDirectory, previewRelativePath); + if (!destination.startsWith(`${temporaryDirectory}${path.sep}`)) { + throw new Error(`Invalid visual preview path: ${asset.relativePath}`); + } + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(asset.absolutePath, destination); + assets.push({ ...asset, absolutePath: destination }); + } + return { evidence: { ...evidence, assets }, temporaryDirectory }; + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function scrubVisualPreviewDirectory(worktreePath: string): Promise { + const previewDirectory = path.resolve(worktreePath, VISUAL_PREVIEW_DIRECTORY); + await rm(previewDirectory, { recursive: true, force: true }); + + const git = createHooklessGit(worktreePath); + const indexedPreviews = (await git.raw(['ls-files', '--', VISUAL_PREVIEW_DIRECTORY])).trim(); + if (indexedPreviews) { + await git.raw(['restore', '--source=HEAD', '--staged', '--worktree', '--', VISUAL_PREVIEW_DIRECTORY]); + } +} + +async function currentPreviewChangePaths(worktreePath: string): Promise { + const git = createHooklessGit(worktreePath); + const statusPaths = (await git.status()).files.map(file => file.path); + const ignoredPreviewPaths = (await git.raw([ + 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', VISUAL_PREVIEW_DIRECTORY + ])).split('\0').filter(Boolean); + return [...new Set([...statusPaths, ...ignoredPreviewPaths])]; +} + +/** + * Captures current preview evidence outside the repository, then restores the + * preview directory to HEAD so a later `git add .` cannot commit runtime media. + */ +export async function prepareVisualPreviewEvidence({ + worktreePath, + settings, + taskId, + changedFiles +}: PrepareVisualPreviewEvidenceOptions): Promise { + let prepared: PreparedVisualPreviewEvidence | undefined; + let preparationFailed = false; + let preparationError: unknown; + try { + const files = changedFiles ?? await currentPreviewChangePaths(worktreePath); + const evidence = await collectVisualPreviewEvidence({ worktreePath, changedFiles: files, settings }); + prepared = await copyEvidenceToTemporaryDirectory(evidence, taskId); + } catch (error) { + preparationFailed = true; + preparationError = error; + } + + try { + await scrubVisualPreviewDirectory(worktreePath); + } catch (error) { + await cleanupPreparedVisualPreviewEvidence(prepared); + throw error; + } + + if (preparationFailed) throw preparationError; + return prepared!; +} + +export async function cleanupPreparedVisualPreviewEvidence( + prepared: PreparedVisualPreviewEvidence | undefined +): Promise { + if (!prepared?.temporaryDirectory) return; + await rm(prepared.temporaryDirectory, { recursive: true, force: true }); +} + +function markdownText(value: string): string { + return value.replace(/([\\`*_[\]{}()<>#+.!|])/g, '\\$1'); +} + +function markdownTarget(target: string): string { + return /[\s()]/.test(target) ? `<${target.replaceAll('>', '%3E')}>` : target; +} + +export function renderVisualPreviewSection( + evidence: VisualPreviewEvidence, + options: RenderVisualPreviewOptions +): string { + const assets = options.useLocalPaths ? evidence.assets : []; + if (assets.length === 0 && evidence.toolSuggestions.length === 0) return ''; + const parts = [VISUAL_PREVIEW_MARKER, '## Visual preview']; + + for (const asset of assets) { + const target = asset.absolutePath; + parts.push(`### ${markdownText(asset.title)}`); + parts.push(`![${asset.type === 'image' ? markdownText(asset.title) : ''}](${markdownTarget(target)})`); + if (asset.description) parts.push(markdownText(asset.description)); + } + + if (evidence.toolSuggestions.length > 0) { + parts.push('### Suggested agent tools'); + parts.push(evidence.toolSuggestions + .map(suggestion => `- **${markdownText(suggestion.name)}:** ${markdownText(suggestion.reason)}`) + .join('\n')); + } + + return parts.join('\n\n'); +} + +export interface RenderVisualPreviewUploadFailureOptions { + authenticationFailure?: boolean; +} + +export function renderVisualPreviewUploadFailureSection( + evidence: VisualPreviewEvidence, + options: RenderVisualPreviewUploadFailureOptions = {}, +): string { + const parts = [ + VISUAL_PREVIEW_MARKER, + '## Visual preview', + 'Preview media was generated but could not be uploaded to GitHub. No preview files were committed.' + ]; + if (options.authenticationFailure) { + parts.push('### Restore preview uploads'); + parts.push( + 'An instance administrator must open the ProPR Web UI, go to **Settings → Visual preview uploads**, ' + + 'and add or replace the personal access token. The token must have access to this repository. GitHub ' + + 'rejects GitHub App user (`ghu_`) and installation (`ghs_`) tokens for attachments. A server operator can ' + + 'alternatively set `GITHUB_VISUAL_PREVIEW_TOKEN`; that environment override takes precedence over the Web ' + + 'UI credential. Then request the visual preview again.', + ); + } + if (evidence.toolSuggestions.length > 0) { + parts.push('### Suggested agent tools'); + parts.push(evidence.toolSuggestions + .map(suggestion => `- **${markdownText(suggestion.name)}:** ${markdownText(suggestion.reason)}`) + .join('\n')); + } + return parts.join('\n\n'); +} + +export function appendVisualPreviewSection(body: string, section: string): string { + if (!section) return body.replace(VISUAL_PREVIEW_SLOT, ''); + if (body.includes(VISUAL_PREVIEW_SLOT)) return body.replace(VISUAL_PREVIEW_SLOT, section); + return `${body.trim()}\n\n---\n\n${section}`; +} + +export function buildVisualPreviewPrompt(settings: VisualPreviewSettings): string { + if (!settings.enabled) return ''; + const requestedTypes = settings.types.join(' and '); + const additionalInstructions = settings.instructions + ? `\nRepository-specific capture instructions (apply only to preview generation):\n${settings.instructions}\n` + : ''; + + return ` +**VISUAL PREVIEW REQUIREMENT:** +Visual previews are enabled for this repository. After implementing and testing, decide whether the result is perceptible visually to a user. If it is not visually perceptible, do not create preview files. If it is visually perceptible: +- Treat previews as evidence only: never expand the implementation scope. Do not create or update preview files when the current request produces no implementation changes, unless the user explicitly asks to generate or refresh previews for changes already present on the branch. +- Generate focused ${requestedTypes} preview evidence of the current change using the project’s existing, relevant tooling (for example a headless browser, Storybook, an Android/iOS emulator, or a project-native renderer). +- Capture the changed state itself, not generic application screens. Use realistic viewport/device states and follow the repository-specific instructions below when present. +- Store each preview under the transient runtime directory \`${VISUAL_PREVIEW_DIRECTORY}/\`; never commit that directory yourself. Use portable filenames and only these formats: PNG/JPEG/GIF/SVG/WebP for images; MP4/MOV/WebM for videos. Keep every file below 10 MB. For video, prefer H.264 in MP4 for browser compatibility. +- Write \`${VISUAL_PREVIEW_MANIFEST}\` with this shape: \`{"previews":[{"path":".propr/previews/desktop.png","title":"Desktop dialog","description":"The changed dialog at desktop width"}],"toolSuggestions":[{"name":"Playwright Chromium","reason":"Needed to capture the running web UI"}]}\`. The manifest may contain an empty previews array when capture is blocked. +- Do not link to local preview or manifest paths in your final response. ProPR reads the manifest and publishes the preview attachments separately. +- Do not fabricate a preview or hand-draw a substitute. If the project cannot be run or the needed capture tool is unavailable, record concise, actionable \`toolSuggestions\` in the manifest describing what should be installed in the agent image and why. +- Never include credentials, tokens, personal data, or unrelated screens in preview media. +${additionalInstructions}`; +} diff --git a/packages/core/test/visualPreviewConfig.test.ts b/packages/core/test/visualPreviewConfig.test.ts new file mode 100644 index 000000000..96255bc16 --- /dev/null +++ b/packages/core/test/visualPreviewConfig.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { + normalizeStoredVisualPreviewSettings, + resolveRepositoryVisualPreviewSettings, + type RepoToMonitor +} from '../src/config/configManager.js'; +import { db } from '../src/db/connection.js'; + +after(async () => { + await db.destroy(); +}); + +test('stored visual preview settings are backward compatible and sanitized', () => { + assert.deepEqual(normalizeStoredVisualPreviewSettings(undefined), { + enabled: false, + types: ['image'] + }); + assert.deepEqual(normalizeStoredVisualPreviewSettings({ + enabled: true, + types: ['video', 'invalid', 'video'], + instructions: ' Focus the changed dialog. ' + }), { + enabled: true, + types: ['video'], + instructions: 'Focus the changed dialog.' + }); +}); + +test('repository visual preview settings are branch independent', () => { + const repos: RepoToMonitor[] = [ + { id: 'main', name: 'integry/propr', enabled: true, baseBranch: 'main' }, + { + id: 'release', + name: 'INTEGRY/PROPR', + enabled: true, + baseBranch: 'release', + visualPreview: { enabled: true, types: ['image', 'video'], instructions: 'Show both breakpoints.' } + } + ]; + + assert.deepEqual(resolveRepositoryVisualPreviewSettings(repos, 'integry/propr'), { + enabled: true, + types: ['image', 'video'], + instructions: 'Show both breakpoints.' + }); + assert.deepEqual(resolveRepositoryVisualPreviewSettings(repos, 'integry/other'), { + enabled: false, + types: ['image'] + }); +}); diff --git a/packages/core/test/visualPreviewOAuthCredentialService.test.ts b/packages/core/test/visualPreviewOAuthCredentialService.test.ts new file mode 100644 index 000000000..8d2012a42 --- /dev/null +++ b/packages/core/test/visualPreviewOAuthCredentialService.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, test } from 'node:test'; +import knex, { type Knex } from 'knex'; +import { db as defaultDatabase } from '../src/db/connection.js'; +import { up as createVisualPreviewOAuthCredentials } from '../src/db/migrations/20260903000000_create_visual_preview_oauth_credentials.js'; +import { + VisualPreviewCredentialError, + VisualPreviewOAuthCredentialService, +} from '../src/services/visualPreviewOAuthCredentialService.js'; + +let database: Knex; + +beforeEach(async () => { + database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await createVisualPreviewOAuthCredentials(database); +}); + +afterEach(async () => database.destroy()); +after(async () => defaultDatabase.destroy()); + +function createService(fetchImpl: typeof fetch = fetch) { + return new VisualPreviewOAuthCredentialService(database, { + SYSTEM_TASK_SECRET: 'test-only-shared-encryption-secret', + GH_OAUTH_CLIENT_ID: 'client-id', + GH_OAUTH_CLIENT_SECRET: 'client-secret', + }, fetchImpl); +} + +test('encrypts a captured administrator credential and resolves it for a worker', async () => { + const service = createService(); + assert.equal(await service.captureFromLogin({ + githubUserId: '1', + githubUsername: 'admin', + source: 'github', + accessToken: 'gho_access-secret', + refreshToken: 'ghr_refresh-secret', + }), true); + + const row = await database('visual_preview_oauth_credentials').first(); + assert.equal(String(row.access_token_encrypted).includes('gho_access-secret'), false); + assert.equal(String(row.refresh_token_encrypted).includes('ghr_refresh-secret'), false); + assert.equal(await service.resolveUploadToken(), 'gho_access-secret'); +}); + +test('rejects GitHub App user and installation tokens before storing them', async () => { + const service = createService(); + for (const accessToken of ['ghu_user-access', 'ghs_installation']) { + await assert.rejects(service.replace({ + githubUserId: '1', githubUsername: 'admin', source: 'github', accessToken, + }), /GitHub App user and installation tokens are not supported/); + } + assert.equal((await service.getStatus()).status, 'missing'); +}); + +test('does not silently replace a healthy credential when another admin logs in', async () => { + const service = createService(); + await service.captureFromLogin({ + githubUserId: '1', githubUsername: 'first', source: 'github', accessToken: 'gho_first', + }); + assert.equal(await service.captureFromLogin({ + githubUserId: '2', githubUsername: 'second', source: 'github', accessToken: 'gho_second', + }), false); + assert.equal(await service.resolveUploadToken(), 'gho_first'); + + await service.replace({ + githubUserId: '2', githubUsername: 'second', source: 'github', accessToken: 'gho_second', + }); + assert.equal(await service.resolveUploadToken(), 'gho_second'); +}); + +test('keeps a personal access token dedicated to uploads instead of copying it into a browser session', async () => { + const service = createService(); + await service.replace({ + githubUserId: '1', + githubUsername: 'preview-bot', + source: 'static_token', + accessToken: 'github_pat_preview-secret', + }); + + assert.equal(await service.resolveUploadToken(), 'github_pat_preview-secret'); + assert.equal(await service.refreshAndGetForOwner('1', true), null); + assert.equal((await service.getStatus()).status, 'active'); +}); + +test('refreshes an expiring OAuth grant and rotates both persisted tokens', async () => { + let refreshBody: Record | undefined; + const service = createService((async (_input, init) => { + refreshBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + access_token: 'gho_rotated-access', + refresh_token: 'ghr_rotated-refresh', + expires_in: 28_800, + refresh_token_expires_in: 15_897_600, + }); + }) as typeof fetch); + await service.replace({ + githubUserId: '1', + githubUsername: 'admin', + source: 'github', + accessToken: 'gho_old-access', + refreshToken: 'ghr_old-refresh', + accessTokenExpiresAt: Date.now() + 30_000, + }); + + assert.equal(await service.refreshIfNeeded(), 'refreshed'); + assert.equal(refreshBody?.refresh_token, 'ghr_old-refresh'); + assert.equal(await service.resolveUploadToken(), 'gho_rotated-access'); + const row = await database('visual_preview_oauth_credentials').first(); + assert.equal(String(row.refresh_token_encrypted).includes('ghr_rotated-refresh'), false); + assert.ok(row.last_refreshed_at); +}); + +test('marks an unrecoverable refresh failure for administrator reconnection', async () => { + const service = createService((async () => Response.json({ error: 'bad_refresh_token' })) as typeof fetch); + await service.replace({ + githubUserId: '1', + githubUsername: 'admin', + source: 'github', + accessToken: 'gho_old-access', + refreshToken: 'ghr_old-refresh', + accessTokenExpiresAt: Date.now() - 1, + }); + + assert.equal(await service.refreshIfNeeded(), 'reauth-required'); + await assert.rejects( + service.resolveUploadToken(), + (error: unknown) => error instanceof VisualPreviewCredentialError + && error.code === 'VISUAL_PREVIEW_AUTH_REAUTH_REQUIRED', + ); +}); + +test('serializes refreshes across service instances sharing SQLite', async () => { + let refreshRequests = 0; + const fetchImpl = (async () => { + refreshRequests += 1; + await new Promise(resolve => setTimeout(resolve, 50)); + return Response.json({ + access_token: 'gho_once-access', + refresh_token: 'ghr_once-refresh', + expires_in: 28_800, + refresh_token_expires_in: 15_897_600, + }); + }) as typeof fetch; + const first = createService(fetchImpl); + const second = createService(fetchImpl); + await first.replace({ + githubUserId: '1', + githubUsername: 'admin', + source: 'github', + accessToken: 'gho_old-access', + refreshToken: 'ghr_old-refresh', + accessTokenExpiresAt: Date.now() + 30_000, + }); + + await Promise.all([first.refreshIfNeeded(), second.refreshIfNeeded()]); + assert.equal(refreshRequests, 1); + assert.equal(await second.resolveUploadToken(), 'gho_once-access'); +}); diff --git a/packages/core/test/visualPreviewService.test.ts b/packages/core/test/visualPreviewService.test.ts new file mode 100644 index 000000000..631a4eb27 --- /dev/null +++ b/packages/core/test/visualPreviewService.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, test } from 'node:test'; +import { simpleGit } from 'simple-git'; +import { + appendVisualPreviewSection, + buildVisualPreviewPrompt, + cleanupPreparedVisualPreviewEvidence, + collectVisualPreviewEvidence, + prepareVisualPreviewEvidence, + renderVisualPreviewSection, + renderVisualPreviewUploadFailureSection, + VISUAL_PREVIEW_MARKER, + VISUAL_PREVIEW_SLOT +} from '../src/services/visualPreviewService.js'; + +const temporaryDirectories: string[] = []; + +async function createWorktree(): Promise { + const worktree = await mkdtemp(path.join(tmpdir(), 'propr-visual-preview-')); + temporaryDirectories.push(worktree); + await mkdir(path.join(worktree, '.propr/previews'), { recursive: true }); + return worktree; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); +}); + +test('visual preview prompt is conditional and carries repository instructions', () => { + assert.equal(buildVisualPreviewPrompt({ enabled: false, types: ['image'] }), ''); + const prompt = buildVisualPreviewPrompt({ + enabled: true, + types: ['image', 'video'], + instructions: 'Capture separate desktop and mobile views.' + }); + + assert.match(prompt, /perceptible visually/); + assert.match(prompt, /never expand the implementation scope/); + assert.match(prompt, /explicitly asks to generate or refresh previews/); + assert.match(prompt, /\.propr\/previews\/manifest\.json/); + assert.match(prompt, /Do not link to local preview or manifest paths/); + assert.match(prompt, /image and video/); + assert.match(prompt, /Capture separate desktop and mobile views\./); + assert.match(prompt, /toolSuggestions/); +}); + +test('collects only changed, selected, regular preview files and applies manifest metadata', async () => { + const worktree = await createWorktree(); + await writeFile(path.join(worktree, '.propr/previews/desktop.png'), 'png'); + await writeFile(path.join(worktree, '.propr/previews/empty.png'), ''); + await writeFile(path.join(worktree, '.propr/previews/walkthrough.mp4'), 'video'); + await writeFile(path.join(worktree, 'outside.png'), 'outside'); + await symlink(path.join(worktree, 'outside.png'), path.join(worktree, '.propr/previews/symlink.png')); + await writeFile(path.join(worktree, '.propr/previews/manifest.json'), JSON.stringify({ + previews: [{ + path: 'desktop.png', + title: 'Changed settings [desktop]', + description: 'The new preview controls.' + }], + toolSuggestions: [{ name: 'Android emulator', reason: 'Capture the native mobile layout.' }] + })); + + const evidence = await collectVisualPreviewEvidence({ + worktreePath: worktree, + changedFiles: [ + '.propr/previews/desktop.png', + '.propr/previews/empty.png', + '.propr/previews/walkthrough.mp4', + '.propr/previews/symlink.png', + '.propr/previews/manifest.json', + 'outside.png' + ], + settings: { enabled: true, types: ['image'] } + }); + + assert.deepEqual(evidence.assets.map(asset => ({ + relativePath: asset.relativePath, + type: asset.type, + title: asset.title, + description: asset.description + })), [{ + relativePath: '.propr/previews/desktop.png', + type: 'image', + title: 'Changed settings [desktop]', + description: 'The new preview controls.' + }]); + assert.deepEqual(evidence.toolSuggestions, [{ + name: 'Android emulator', + reason: 'Capture the native mobile layout.' + }]); +}); + +test('renders upload-ready local media without committed-file fallbacks', async () => { + const worktree = await createWorktree(); + const relativePath = '.propr/previews/desktop view.png'; + const absolutePath = path.join(worktree, relativePath); + await writeFile(absolutePath, 'png'); + const evidence = { + assets: [{ + relativePath, + absolutePath, + type: 'image' as const, + title: 'Settings [desktop]', + description: 'Focused on the changed controls.' + }], + toolSuggestions: [] + }; + + const local = renderVisualPreviewSection(evidence, { + useLocalPaths: true + }); + assert.match(local, new RegExp(VISUAL_PREVIEW_MARKER)); + assert.match(local, /Settings \\\[desktop\\\]/); + assert.match(local, /\(<.*desktop view\.png>\)/); + + assert.equal(renderVisualPreviewSection(evidence, {}), ''); + const failure = renderVisualPreviewUploadFailureSection(evidence); + assert.match(failure, /could not be uploaded to GitHub/); + assert.match(failure, /No preview files were committed/); + assert.doesNotMatch(failure, /desktop view\.png/); + const authenticationFailure = renderVisualPreviewUploadFailureSection(evidence, { authenticationFailure: true }); + assert.match(authenticationFailure, /Settings → Visual preview uploads/); + assert.match(authenticationFailure, /add or replace the personal access token/); + assert.match(authenticationFailure, /GitHub App user \(`ghu_`\)/); + assert.match(authenticationFailure, /GITHUB_VISUAL_PREVIEW_TOKEN/); + assert.equal(appendVisualPreviewSection(`Before\n\n${VISUAL_PREVIEW_SLOT}\n\nAfter`, failure), `Before\n\n${failure}\n\nAfter`); +}); + +test('renders videos only as local upload references', () => { + const evidence = { + assets: [{ + relativePath: '.propr/previews/walkthrough.mp4', + absolutePath: '/worktree/.propr/previews/walkthrough.mp4', + type: 'video' as const, + title: 'Settings walkthrough' + }], + toolSuggestions: [] + }; + + const local = renderVisualPreviewSection(evidence, { + useLocalPaths: true + }); + assert.match(local, /!\[\]\(\/worktree\/\.propr\/previews\/walkthrough\.mp4\)/); + assert.equal(renderVisualPreviewSection(evidence, {}), ''); +}); + +test('removing an empty preview slot preserves unrelated body whitespace', () => { + const body = ` Before\n\n\nUnrelated spacing\n\n${VISUAL_PREVIEW_SLOT}\n\nAfter `; + assert.equal( + appendVisualPreviewSection(body, ''), + ' Before\n\n\nUnrelated spacing\n\n\n\nAfter ' + ); +}); + +test('stages changed previews outside the repository and restores the preview directory to HEAD', async () => { + const worktree = await createWorktree(); + const git = simpleGit(worktree); + await git.init(); + await git.addConfig('user.name', 'ProPR Test'); + await git.addConfig('user.email', 'test@propr.dev'); + await writeFile(path.join(worktree, '.propr/previews/tracked.png'), 'original'); + await git.add('.'); + await git.commit('initial preview'); + + await writeFile(path.join(worktree, '.propr/previews/tracked.png'), 'updated'); + await writeFile(path.join(worktree, '.propr/previews/desktop.png'), 'desktop'); + await writeFile(path.join(worktree, '.propr/previews/manifest.json'), JSON.stringify({ + previews: [{ path: 'desktop.png', title: 'Desktop settings' }] + })); + await git.add('.propr/previews'); + + const prepared = await prepareVisualPreviewEvidence({ + worktreePath: worktree, + settings: { enabled: true, types: ['image'] }, + taskId: 'task/42' + }); + + assert.ok(prepared.temporaryDirectory?.startsWith(path.join(tmpdir(), 'propr-previews', 'task-42-'))); + assert.deepEqual(prepared.evidence.assets.map(asset => asset.title), ['Desktop settings', 'Tracked']); + assert.equal(await readFile(prepared.evidence.assets[0].absolutePath, 'utf8'), 'desktop'); + assert.equal(await readFile(path.join(worktree, '.propr/previews/tracked.png'), 'utf8'), 'original'); + await assert.rejects(access(path.join(worktree, '.propr/previews/desktop.png'))); + await assert.rejects(access(path.join(worktree, '.propr/previews/manifest.json'))); + assert.equal((await git.status()).files.length, 0); + + const stagedDirectory = prepared.temporaryDirectory; + await cleanupPreparedVisualPreviewEvidence(prepared); + await assert.rejects(access(stagedDirectory!)); +}); + +test('stages previews even when the repository ignores the transient directory', async () => { + const worktree = await createWorktree(); + const git = simpleGit(worktree); + await git.init(); + await git.addConfig('user.name', 'ProPR Test'); + await git.addConfig('user.email', 'test@propr.dev'); + await writeFile(path.join(worktree, '.gitignore'), '.propr/previews/\n'); + await git.add('.gitignore'); + await git.commit('ignore runtime previews'); + + await writeFile(path.join(worktree, '.propr/previews/mobile.png'), 'mobile'); + await writeFile(path.join(worktree, '.propr/previews/manifest.json'), JSON.stringify({ + previews: [{ path: 'mobile.png', title: 'Mobile settings' }] + })); + + const prepared = await prepareVisualPreviewEvidence({ + worktreePath: worktree, + settings: { enabled: true, types: ['image'] }, + taskId: 'ignored-preview' + }); + + assert.deepEqual(prepared.evidence.assets.map(asset => asset.title), ['Mobile settings']); + assert.equal(await readFile(prepared.evidence.assets[0].absolutePath, 'utf8'), 'mobile'); + await assert.rejects(access(path.join(worktree, '.propr/previews'))); + await cleanupPreparedVisualPreviewEvidence(prepared); +}); diff --git a/propr-ui/src/api/proprTypes.ts b/propr-ui/src/api/proprTypes.ts index 07cbfb8f6..9f37c899e 100644 --- a/propr-ui/src/api/proprTypes.ts +++ b/propr-ui/src/api/proprTypes.ts @@ -120,6 +120,12 @@ export interface MonitoredRepo { enabled: boolean; /** Whether failed CI triggers an automatic follow-up. Missing legacy values are off. */ autoFollowupOnFailedCi?: boolean; + /** Generated media to embed in PRs when a change has a visible result. */ + visualPreview?: { + enabled: boolean; + types: Array<'image' | 'video'>; + instructions?: string; + }; alias?: string; baseBranch?: string; starred?: boolean; diff --git a/propr-ui/src/api/visualPreviewAuthApi.ts b/propr-ui/src/api/visualPreviewAuthApi.ts new file mode 100644 index 000000000..76f5c46e1 --- /dev/null +++ b/propr-ui/src/api/visualPreviewAuthApi.ts @@ -0,0 +1,53 @@ +import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; + +export type VisualPreviewAuthStatusValue = 'active' | 'reauth_required' | 'missing'; + +export interface VisualPreviewAuthStatus { + configured: boolean; + source?: 'github' | 'connect' | 'static_token' | 'environment'; + status: VisualPreviewAuthStatusValue; + githubUsername?: string; + currentUsername?: string; + currentLoginTokenType?: 'supported' | 'github_app_user' | 'unsupported' | 'missing'; + canUseCurrentLogin: boolean; + accessTokenExpiresAt?: number; + refreshTokenExpiresAt?: number; + lastErrorCode?: string; + updatedAt?: string; +} + +export async function getVisualPreviewAuthStatus(): Promise { + const response = await apiFetch(`${API_BASE_URL}/api/config/visual-preview-auth`, { + credentials: 'include', + }); + await handleApiResponse(response); + return response.json(); +} + +export async function connectVisualPreviewPersonalAccessToken(token: string): Promise { + const response = await apiFetch(`${API_BASE_URL}/api/config/visual-preview-auth/token`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + await handleApiResponse(response); + return response.json(); +} + +export async function connectCurrentGitHubLoginForVisualPreviews(): Promise { + const response = await apiFetch(`${API_BASE_URL}/api/config/visual-preview-auth`, { + method: 'POST', + credentials: 'include', + }, { replayMutationAfterTokenRefresh: true }); + await handleApiResponse(response); + return response.json(); +} + +export async function disconnectVisualPreviewAuth(): Promise { + const response = await apiFetch(`${API_BASE_URL}/api/config/visual-preview-auth`, { + method: 'DELETE', + credentials: 'include', + }, { replayMutationAfterTokenRefresh: true }); + await handleApiResponse(response); +} diff --git a/propr-ui/src/components/RepositoryListContent.tsx b/propr-ui/src/components/RepositoryListContent.tsx index 500ad45b3..f4e71994b 100644 --- a/propr-ui/src/components/RepositoryListContent.tsx +++ b/propr-ui/src/components/RepositoryListContent.tsx @@ -33,6 +33,7 @@ interface RepositoryListContentProps { selectedRepoId: string | null; onToggle: (repoId: string) => void; onToggleAutoCiFollowup: (repoId: string) => void; + onUpdateVisualPreview: (repoId: string, settings: NonNullable) => void; onRemove: (repoId: string) => void; onStopIndexing: (repoName: string, baseBranch?: string) => void; onReindex: (repoName: string, baseBranch?: string) => void; @@ -51,6 +52,7 @@ export const RepositoryListContent: React.FC = ({ selectedRepoId, onToggle, onToggleAutoCiFollowup, + onUpdateVisualPreview, onRemove, onStopIndexing, onReindex, @@ -105,6 +107,7 @@ export const RepositoryListContent: React.FC = ({ indexingStatuses={indexingStatuses} onToggle={onToggle} onToggleAutoCiFollowup={onToggleAutoCiFollowup} + onUpdateVisualPreview={onUpdateVisualPreview} onRemove={onRemove} onStopIndexing={onStopIndexing} onReindex={onReindex} diff --git a/propr-ui/src/components/RepositoryListItem.tsx b/propr-ui/src/components/RepositoryListItem.tsx index 38290c357..c6bde3f95 100644 --- a/propr-ui/src/components/RepositoryListItem.tsx +++ b/propr-ui/src/components/RepositoryListItem.tsx @@ -3,6 +3,7 @@ import { Github, RefreshCw, Star, Eye, EyeOff } from 'lucide-react'; import { DeleteRepoDialog } from './DeleteRepoDialog'; import { RepositoryIndexingStatus, MonitoredRepo } from '../api/proprApi'; import { getRepoStatusKey } from '../api/repoIndexingApi'; +import { RepositoryVisualPreviewControl, type RepositoryVisualPreviewSettings } from './RepositoryVisualPreviewControl'; type RepoStatusType = 'indexed' | 'indexing' | 'failed' | 'idle'; @@ -227,6 +228,7 @@ interface RepositoryListItemProps { indexingStatuses: Record; onToggle: (repoId: string) => void; onToggleAutoCiFollowup: (repoId: string) => void; + onUpdateVisualPreview: (repoId: string, settings: RepositoryVisualPreviewSettings) => void; onRemove: (repoId: string) => void | Promise; onStopIndexing: (repoName: string, baseBranch?: string) => void; onReindex: (repoName: string, baseBranch?: string) => void; @@ -242,6 +244,7 @@ export const RepositoryListItem: React.FC = ({ indexingStatuses, onToggle, onToggleAutoCiFollowup, + onUpdateVisualPreview, onRemove, onStopIndexing, onReindex, @@ -360,6 +363,11 @@ export const RepositoryListItem: React.FC = ({ onToggle={onToggleAutoCiFollowup} isReadOnly={isReadOnly} /> + {/* Right Action Gutter: Fixed-width area for maintenance tools */} diff --git a/propr-ui/src/components/RepositoryVisualPreviewControl.test.tsx b/propr-ui/src/components/RepositoryVisualPreviewControl.test.tsx new file mode 100644 index 000000000..7fde9edcd --- /dev/null +++ b/propr-ui/src/components/RepositoryVisualPreviewControl.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { MonitoredRepo } from '../api/proprApi'; +import { RepositoryVisualPreviewControl } from './RepositoryVisualPreviewControl'; + +const repo: MonitoredRepo = { + id: 'repo-1', + name: 'integry/propr', + enabled: true, + visualPreview: { enabled: true, types: ['image'] } +}; + +describe('RepositoryVisualPreviewControl', () => { + it('updates preview types and preserves edited instructions', () => { + const onUpdate = vi.fn(); + render(); + + fireEvent.change(screen.getByRole('textbox', { name: 'Visual preview instructions for integry/propr' }), { + target: { value: ' Capture the responsive menu. ' } + }); + fireEvent.click(screen.getByRole('button', { name: 'Videos' })); + + expect(onUpdate).toHaveBeenLastCalledWith('repo-1', { + enabled: true, + types: ['image', 'video'], + instructions: 'Capture the responsive menu.' + }); + }); + + it('keeps at least one preview type selected', () => { + const onUpdate = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Images' })); + + expect(onUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/propr-ui/src/components/RepositoryVisualPreviewControl.tsx b/propr-ui/src/components/RepositoryVisualPreviewControl.tsx new file mode 100644 index 000000000..cac3bc44b --- /dev/null +++ b/propr-ui/src/components/RepositoryVisualPreviewControl.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { Image, Video } from 'lucide-react'; +import type { MonitoredRepo } from '../api/proprApi'; + +export type RepositoryVisualPreviewSettings = NonNullable; + +interface RepositoryVisualPreviewControlProps { + repo: MonitoredRepo; + onUpdate: (repoId: string, settings: RepositoryVisualPreviewSettings) => void; + isReadOnly: boolean; +} + +export const RepositoryVisualPreviewControl: React.FC = ({ repo, onUpdate, isReadOnly }) => { + const settings: RepositoryVisualPreviewSettings = repo.visualPreview || { enabled: false, types: ['image'] }; + const [instructions, setInstructions] = useState(settings.instructions || ''); + + useEffect(() => setInstructions(settings.instructions || ''), [settings.instructions]); + + if (isReadOnly) return null; + + const settingsWithCurrentInstructions = (): RepositoryVisualPreviewSettings => { + const normalizedInstructions = instructions.trim(); + return { + ...settings, + ...(normalizedInstructions ? { instructions: normalizedInstructions } : { instructions: undefined }) + }; + }; + + const toggleType = (type: 'image' | 'video') => { + const selected = settings.types.includes(type); + if (selected && settings.types.length === 1) return; + onUpdate(repo.id, { + ...settingsWithCurrentInstructions(), + types: selected ? settings.types.filter(candidate => candidate !== type) : [...settings.types, type] + }); + }; + + return ( +
event.stopPropagation()}> + + + {settings.enabled && ( +
event.stopPropagation()}> +
+ + +
+