diff --git a/agent/src/index.ts b/agent/src/index.ts index cd4e9b306..fc7673175 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -68,9 +68,21 @@ export interface SecretHeaderReference { readonly suffix?: string; } +export type GithubAppPermission = "read" | "write"; + +export interface GithubAppHeaderReference { + readonly kind: "github-app-header"; + readonly permissions: Readonly>; +} + +export type ConnectionHeaderValue = + | string + | SecretHeaderReference + | GithubAppHeaderReference; + export interface HttpConnectionDefinition extends ConnectionReference { readonly origin: string; - readonly headers: Readonly>; + readonly headers: Readonly>; readonly methods?: readonly string[]; readonly pathPrefix?: string; readonly redirectOrigins?: readonly HttpConnectionRedirectOrigin[]; @@ -286,10 +298,53 @@ export function bearer(secret: SecretReference): SecretHeaderReference { return secretHeader(secret, { prefix: "Bearer " }); } +const GITHUB_APP_PERMISSION_KEYS = new Set([ + "contents", + "pull_requests", + "issues", + "metadata", + "checks", +]); + +const GITHUB_APP_ORIGINS = new Set([ + "https://api.github.com", + "https://api.githubcopilot.com", +]); + +export function githubApp(options: { + permissions: Readonly>; +}): GithubAppHeaderReference { + const entries = Object.entries(options?.permissions ?? {}); + if (entries.length === 0) { + throw new Error( + "githubApp() requires at least one permission, e.g. githubApp({ permissions: { contents: \"read\" } })", + ); + } + const permissions: Record = {}; + for (const [key, value] of entries) { + if (!GITHUB_APP_PERMISSION_KEYS.has(key)) { + throw new Error( + `githubApp() does not support the ${key} permission; supported keys are ${[...GITHUB_APP_PERMISSION_KEYS].join(", ")}`, + ); + } + if (value !== "read" && value !== "write") { + throw new Error(`githubApp() permissions must be read or write, got ${String(value)}`); + } + if ((key === "metadata" || key === "checks") && value === "write") { + throw new Error(`The ${key} permission is read-only`); + } + permissions[key] = value; + } + return Object.freeze({ + kind: "github-app-header", + permissions: Object.freeze(permissions), + }); +} + export function defineConnection(input: { id: string; origin: string; - headers?: Readonly>; + headers?: Readonly>; methods?: readonly string[]; pathPrefix?: string; redirectOrigins?: readonly HttpConnectionRedirectOrigin[]; @@ -299,6 +354,17 @@ export function defineConnection(input: { if (origin.protocol !== "https:" || origin.pathname !== "/") { throw new Error("Connection origins must be HTTPS origins without a path"); } + for (const [, value] of Object.entries(input.headers ?? {})) { + if ( + typeof value === "object" && + value.kind === "github-app-header" && + !GITHUB_APP_ORIGINS.has(origin.origin) + ) { + throw new Error( + "githubApp() headers are only valid on GitHub API origins (https://api.github.com, https://api.githubcopilot.com)", + ); + } + } for (const [name, value] of Object.entries(input.headers ?? {})) { if ( [ diff --git a/cli/src/api.ts b/cli/src/api.ts index c64585285..1a85b7a45 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -271,6 +271,35 @@ export class OpenComputerClient { ); } + githubStatus(input: { projectId: string }) { + return this.request<{ + environments: Array<{ + environment: "development" | "production"; + state: string; + app?: { mode: string; slug: string }; + installation?: { accountLogin: string }; + scopeMode?: "all" | "selected"; + selectedRepositoryCount?: number; + }>; + ocAppAvailable: boolean; + }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/github`, + ); + } + + githubConnect(input: { + projectId: string; + environments: Array<"development" | "production">; + }) { + return this.request<{ installUrl?: string }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/github/connect`, + { + method: "POST", + body: JSON.stringify({ environments: input.environments }), + }, + ); + } + deleteSecret(input: { projectId: string; name: string; @@ -438,6 +467,10 @@ export class OpenComputerClient { prefix?: string; suffix?: string; } + | { + kind: "github_app"; + permissions: Record; + } >; methods?: string[]; pathPrefix?: string; diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 980274ddb..8ac0197f8 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -676,7 +676,10 @@ export async function runCommand( allowedOrigins = built.httpConnections .filter((connection) => Object.values(connection.headers).some( - (value) => typeof value !== "string" && value.name === name, + (value) => + typeof value !== "string" && + value.kind === "secret" && + value.name === name, ), ) .flatMap((connection) => [ @@ -798,6 +801,75 @@ export async function runCommand( throw new Error("Use `opencomputer env set|list|remove `."); } + if (command === "github") { + const action = args.shift(); + const projectReference = option(args, "--project"); + const project = await selectedProject( + client, + config, + projectReference, + !globals.json, + ); + if (action === "status" || action === undefined) { + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + const status = await client.githubStatus({ + projectId: project.projectId, + }); + if (globals.json) printJSON(status); + else { + for (const entry of status.environments) { + const app = entry.app + ? `${entry.app.slug} (${entry.app.mode === "oc_app" ? "shared" : "dedicated"})` + : "—"; + const scope = + entry.scopeMode === "selected" + ? `${entry.selectedRepositoryCount ?? 0} selected repositories` + : entry.scopeMode === "all" + ? "all granted repositories" + : ""; + process.stdout.write( + `${entry.environment.padEnd(12)} ${entry.state.padEnd(14)} ${app}` + + (entry.installation ? ` @${entry.installation.accountLogin}` : "") + + (scope ? ` ${scope}` : "") + + "\n", + ); + } + if ( + status.environments.every((entry) => entry.state === "not_connected") + ) { + process.stdout.write( + "Connect with `opencomputer github connect` or from the dashboard Repositories tab.\n", + ); + } + } + return; + } + if (action === "connect") { + const environmentValue = option(args, "--environment"); + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + const result = await client.githubConnect({ + projectId: project.projectId, + environments: environmentValue + ? [environmentOption(environmentValue)] + : ["development", "production"], + }); + if (globals.json) printJSON(result); + else if (result.installUrl) { + process.stdout.write( + "Open this URL to install the OpenComputer GitHub app:\n" + + ` ${result.installUrl}\n` + + "Pick repositories on GitHub, then manage scope from the dashboard Repositories tab.\n", + ); + } else { + process.stdout.write("GitHub is connected for this project.\n"); + } + return; + } + throw new Error( + "Use `opencomputer github status` or `opencomputer github connect`.", + ); + } + if (command === "webhooks") { const action = args.shift(); const projectReference = option(args, "--project"); diff --git a/cli/src/dev.ts b/cli/src/dev.ts index 8e402d728..6eec52a72 100644 --- a/cli/src/dev.ts +++ b/cli/src/dev.ts @@ -75,7 +75,7 @@ function secretOrigins(results: DevelopmentResults): Map { for (const { built } of results) { for (const connection of built.httpConnections) { for (const header of Object.values(connection.headers)) { - if (typeof header === "string") continue; + if (typeof header === "string" || header.kind !== "secret") continue; const current = origins.get(header.name) ?? new Set(); current.add(connection.origin); for (const redirect of connection.redirectOrigins ?? []) { @@ -516,6 +516,33 @@ export async function runCloudDevelopment( ? `React: starting local Vite app\n` : `React: not included\n`), ); + const usesGithubApp = initial.some(({ built }) => + built.httpConnections.some((connection) => + Object.values(connection.headers).some( + (header) => typeof header !== "string" && header.kind === "github_app", + ), + ), + ); + if (usesGithubApp) { + try { + const github = await client.githubStatus({ + projectId: binding.projectId, + }); + const development = github.environments.find( + (entry) => entry.environment === "development", + ); + if (development && development.state !== "connected") { + process.stdout.write( + `GitHub: not connected for development — githubApp() calls will fail +` + + ` Run \`opencomputer github connect\` or open the dashboard Repositories tab +`, + ); + } + } catch { + // Connection status is advisory; never block the dev loop on it. + } + } if (spa) web = await startReactDevServer(projectRoot); watcher = watch( resolve(projectRoot, "opencomputer"), diff --git a/cli/src/index.ts b/cli/src/index.ts index 465cd5d8e..d5f04be13 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -63,6 +63,8 @@ Usage: opencomputer env set [--environment development|production] [--agent |current] opencomputer env list [--environment development|production] [--agent |current] opencomputer env remove [--environment development|production] [--agent |current] + opencomputer github status [--project ] + opencomputer github connect [--environment development|production] [--project ] opencomputer webhooks list [--environment development|production] [--agent |current] opencomputer webhooks create [--environment development|production] [--agent |current] opencomputer webhooks enable [--project ] diff --git a/cli/src/project.test.ts b/cli/src/project.test.ts index e910acbd8..5de7609f7 100644 --- a/cli/src/project.test.ts +++ b/cli/src/project.test.ts @@ -454,6 +454,81 @@ export default function Agent() { } }); +test("the compiler records app-minted GitHub connections with literal permissions", async () => { + const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-github-app-")); + const root = resolve(parent, "app"); + try { + const initialized = await initializeAgentProject(root); + await writeFile( + resolve(initialized.agentRoot, "agent.ts"), + `import { defineConnection, githubApp } from "@opencomputer/agent"; + +export const github = defineConnection({ + id: "github", + origin: "https://api.github.com", + headers: { + Authorization: githubApp({ + permissions: { contents: "read", pull_requests: "write" }, + }), + }, +}); +export default function Agent() { + return "Use GitHub."; +} +`, + ); + const built = await buildAgentArtifact(initialized.agentRoot); + await assert.doesNotReject( + import( + `${pathToFileURL(resolve(initialized.agentRoot, ".opencomputer", "runtime", "opencomputer-agent.js")).href}?test=${crypto.randomUUID()}` + ), + ); + assert.deepEqual(built.httpConnections, [ + { + id: "github", + origin: "https://api.github.com", + headers: { + Authorization: { + kind: "github_app", + permissions: { contents: "read", pull_requests: "write" }, + }, + }, + }, + ]); + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + +test("the compiler rejects githubApp permissions that are not literal", async () => { + const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-github-bad-")); + const root = resolve(parent, "app"); + try { + const initialized = await initializeAgentProject(root); + await writeFile( + resolve(initialized.agentRoot, "agent.ts"), + `import { defineConnection, githubApp } from "@opencomputer/agent"; + +const shared = { contents: "read" } as const; +export const github = defineConnection({ + id: "github", + origin: "https://api.github.com", + headers: { Authorization: githubApp({ permissions: shared }) }, +}); +export default function Agent() { + return "Use GitHub."; +} +`, + ); + await assert.rejects( + buildAgentArtifact(initialized.agentRoot), + /githubApp\(\) permissions must be an inline object literal/, + ); + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + test("the compiler records managed MCP server definitions", async () => { const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-mcp-")); const root = resolve(parent, "app"); diff --git a/cli/src/project.ts b/cli/src/project.ts index 88723bc24..d82a4bf1b 100644 --- a/cli/src/project.ts +++ b/cli/src/project.ts @@ -34,7 +34,9 @@ export interface HttpConnectionManifest { origin: string; headers: Record< string, - string | { kind: "secret"; name: string; prefix?: string; suffix?: string } + | string + | { kind: "secret"; name: string; prefix?: string; suffix?: string } + | { kind: "github_app"; permissions: Record } >; methods?: string[]; pathPrefix?: string; @@ -1371,10 +1373,63 @@ function connectionHeaderValue( !ts.isIdentifier(expression.expression) ) { throw new Error( - "Connection headers must be string literals, bearer(useSecret()), or secretHeader(useSecret())", + "Connection headers must be string literals, bearer(useSecret()), secretHeader(useSecret()), or githubApp()", ); } const helper = expression.expression.text; + if (helper === "githubApp") { + const options = expression.arguments[0]; + if (!options || !ts.isObjectLiteralExpression(options)) { + throw new Error( + "githubApp() requires an object literal, e.g. githubApp({ permissions: { contents: \"read\" } })", + ); + } + const permissionsProperty = objectProperty(options, "permissions"); + if ( + !permissionsProperty || + !ts.isObjectLiteralExpression(permissionsProperty) + ) { + throw new Error( + "githubApp() permissions must be an inline object literal with literal values", + ); + } + const permissions: Record = {}; + for (const property of permissionsProperty.properties) { + if ( + !ts.isPropertyAssignment(property) || + (!ts.isIdentifier(property.name) && + !ts.isStringLiteralLike(property.name)) + ) { + throw new Error( + "githubApp() permissions must use literal keys and literal values", + ); + } + const key = ts.isIdentifier(property.name) + ? property.name.text + : property.name.text; + const value = literalStringValue( + property.initializer, + `githubApp permission ${key}`, + ); + if (value !== "read" && value !== "write") { + throw new Error( + `githubApp() permission ${key} must be "read" or "write"`, + ); + } + if ( + !["contents", "pull_requests", "issues", "metadata", "checks"].includes( + key, + ) + ) { + throw new Error(`githubApp() does not support the ${key} permission`); + } + permissions[key] = value; + } + if (Object.keys(permissions).length === 0) { + throw new Error("githubApp() requires at least one permission"); + } + return { kind: "github_app", permissions }; + } if (helper === "bearer") { const secret = expression.arguments[0]; if (!secret) throw new Error("bearer() requires useSecret()"); @@ -1593,6 +1648,18 @@ export const useSecret = (value) => { }; export const secretHeader = (secret, options = {}) => Object.freeze({ kind: "secret-header", secret, ...options }); export const bearer = (secret) => secretHeader(secret, { prefix: "Bearer " }); +export const githubApp = (options) => { + const entries = Object.entries(options?.permissions || {}); + if (!entries.length) throw new Error("githubApp() requires at least one permission"); + const permissions = {}; + for (const [key, value] of entries) { + if (!["contents", "pull_requests", "issues", "metadata", "checks"].includes(key)) throw new Error("githubApp() does not support the " + key + " permission"); + if (value !== "read" && value !== "write") throw new Error("githubApp() permissions must be read or write"); + if ((key === "metadata" || key === "checks") && value === "write") throw new Error("The " + key + " permission is read-only"); + permissions[key] = value; + } + return Object.freeze({ kind: "github-app-header", permissions: Object.freeze(permissions) }); +}; export const defineConnection = (input) => { const connectionId = id(input.id, "defineConnection"); const origin = new URL(input.origin); diff --git a/cloudflare-workers/api-edge/src/index.ts b/cloudflare-workers/api-edge/src/index.ts index a30a2363a..edd3c97fd 100644 --- a/cloudflare-workers/api-edge/src/index.ts +++ b/cloudflare-workers/api-edge/src/index.ts @@ -52,6 +52,7 @@ import { createAPIKey, hashAPIKey } from "./api_keys"; import { handleAgentWebhookInvocation, handleManagedAgentChannelConnection, + handleManagedAgentGithubPublic, proxyManagedAgents, } from "./managed_agents"; @@ -3633,6 +3634,12 @@ export default { if (path.startsWith("/api/agent-webhooks/")) { return handleAgentWebhookInvocation(req, env); } + // GitHub App browser callbacks and webhook ingress. Unauthenticated by + // design: the callbacks are authorized by a one-time state consumed at the + // private edge, the webhook by its HMAC signature there. + if (path.startsWith("/api/managed-agents/github/")) { + return handleManagedAgentGithubPublic(req, env); + } if ( path === "/api/managed-agents" || path.startsWith("/api/managed-agents/") diff --git a/cloudflare-workers/api-edge/src/managed_agents.ts b/cloudflare-workers/api-edge/src/managed_agents.ts index d3892cf2b..2eeffa1f6 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.ts @@ -834,6 +834,38 @@ function isAllowedManagedAgentsRoute(method: string, suffix: string): boolean { ) { return true; } + if ( + (method === "GET" || method === "DELETE") && + /^\/projects\/[^/]+\/github$/.test(suffix) + ) { + return true; + } + if ( + method === "POST" && + /^\/projects\/[^/]+\/github\/(connect|manifest)$/.test(suffix) + ) { + return true; + } + if ( + (method === "GET" || method === "PUT") && + /^\/projects\/[^/]+\/github\/repositories$/.test(suffix) + ) { + return true; + } + if ( + method === "POST" && + /^\/projects\/[^/]+\/github\/apps\/[^/]+\/(webhook-secret|private-key)$/.test( + suffix, + ) + ) { + return true; + } + if ( + method === "DELETE" && + /^\/projects\/[^/]+\/github\/apps\/[^/]+$/.test(suffix) + ) { + return true; + } if (method === "GET" && suffix === "/logs") return true; if (method === "POST" && suffix === "/deployments") return true; if (method === "POST" && suffix === "/benchmarks/warm-pool") return true; @@ -993,6 +1025,84 @@ export async function handleManagedAgentChannelConnection( }); } +// GitHub App public surface: two browser-facing callbacks (setup after an +// installation, manifest conversion after app creation) and the App webhook +// ingress. No API-key auth — the private edge authorizes the callbacks with +// their one-time state and the webhook with its HMAC signature. This public +// origin is what gets baked into GitHub app manifests, never the private +// hostname. +export async function handleManagedAgentGithubPublic( + request: Request, + env: ManagedAgentsEnv, +): Promise { + const url = new URL(request.url); + const base = ( + env.MANAGED_AGENTS_API_URL ?? DEFAULT_MANAGED_AGENTS_API_URL + ).replace(/\/+$/, ""); + let target: URL | null = null; + if ( + request.method === "GET" && + url.pathname === "/api/managed-agents/github/setup" + ) { + target = new URL(`${base}/v1/github/setup${url.search}`); + } else if ( + request.method === "GET" && + url.pathname === "/api/managed-agents/github/manifest/callback" + ) { + target = new URL(`${base}/v1/github/manifest/callback${url.search}`); + } else if ( + request.method === "POST" && + url.pathname === "/api/managed-agents/github/webhooks" + ) { + target = new URL(`${base}/v1/webhooks/github`); + } + if (!target || (target.protocol !== "https:" && target.hostname !== "localhost")) { + return Response.json( + { error: { code: "not_found", message: "Route not found." } }, + { status: 404 }, + ); + } + const headers = new Headers({ "x-request-id": crypto.randomUUID() }); + for (const name of [ + "content-type", + "x-github-event", + "x-github-delivery", + "x-github-hook-installation-target-id", + "x-github-hook-installation-target-type", + "x-hub-signature-256", + ]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + try { + const upstream = await fetch(target, { + method: request.method, + headers, + body: request.method === "POST" ? request.body : undefined, + redirect: "manual", + }); + // Redirects (e.g. manifest conversion -> GitHub install) and HTML + // callback pages pass through verbatim. + return new Response(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }); + } catch (error) { + console.error( + JSON.stringify({ + level: "error", + event: "managed_agents.github_public_failed", + path: url.pathname, + message: error instanceof Error ? error.message : String(error), + }), + ); + return Response.json( + { error: { code: "unavailable", message: "GitHub service is unavailable." } }, + { status: 503 }, + ); + } +} + export async function handleAgentWebhookInvocation( request: Request, env: ManagedAgentsEnv, diff --git a/web/src/components/app-shell-nav.ts b/web/src/components/app-shell-nav.ts index ae9177fc6..df9cfdcd0 100644 --- a/web/src/components/app-shell-nav.ts +++ b/web/src/components/app-shell-nav.ts @@ -3,6 +3,7 @@ import { Bot, Boxes, CalendarClock, + FolderGit2, KeySquare, Layers, MessagesSquare, @@ -86,6 +87,11 @@ export function managedAgentsNav(options: { label: 'Secrets', icon: KeySquare, }, + { + to: `${projectPath}/repositories`, + label: 'Repositories', + icon: FolderGit2, + }, { to: projectPath, label: 'Debug playground', diff --git a/web/src/components/app-shell.test.ts b/web/src/components/app-shell.test.ts index 10e43a89e..357bd3521 100644 --- a/web/src/components/app-shell.test.ts +++ b/web/src/components/app-shell.test.ts @@ -25,6 +25,7 @@ describe('managed agents navigation', () => { 'Schedules', 'Webhooks', 'Secrets', + 'Repositories', 'Debug playground', ]) expect(nav[1]?.items[nav[1].items.length - 1]?.to).toBe( diff --git a/web/src/managed-agents/Detail.tsx b/web/src/managed-agents/Detail.tsx index 745fd63d5..cc5ffc8a8 100644 --- a/web/src/managed-agents/Detail.tsx +++ b/web/src/managed-agents/Detail.tsx @@ -63,6 +63,7 @@ import { sessionsForEnvironment, } from './session-history' import { ManagedProjectSecrets } from './Secrets' +import { ManagedProjectRepositories } from './Repositories' import { ManagedSlackWizard } from './SlackWizard' import { ManagedAgentOutboxes } from './Outboxes' import { ManagedAgentSchedules } from './Schedules' @@ -78,6 +79,7 @@ type DetailTab = | 'schedules' | 'webhooks' | 'secrets' + | 'repositories' function formatDate(value: string) { return new Date(value).toLocaleString() @@ -483,6 +485,7 @@ export default function ManagedAgentDetail({ 'schedules', 'webhooks', 'secrets', + 'repositories', ]) const activeTab = project ? routeTab && projectTabs.has(routeTab) @@ -652,6 +655,9 @@ export default function ManagedAgentDetail({ ...(project ? ([{ id: 'schedules', label: 'Schedules' }] as const) : []), ...(project ? ([{ id: 'webhooks', label: 'Webhooks' }] as const) : []), ...(project ? ([{ id: 'secrets', label: 'Secrets' }] as const) : []), + ...(project + ? ([{ id: 'repositories', label: 'Repositories' }] as const) + : []), ] return ( @@ -1045,6 +1051,13 @@ export default function ManagedAgentDetail({ environment={environment} /> ) : null} + + {activeTab === 'repositories' && project ? ( + + ) : null} ) } diff --git a/web/src/managed-agents/Repositories.tsx b/web/src/managed-agents/Repositories.tsx new file mode 100644 index 000000000..f9b93f966 --- /dev/null +++ b/web/src/managed-agents/Repositories.tsx @@ -0,0 +1,1326 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Check, + ExternalLink, + FolderGit2, + Loader2, + RefreshCw, + Trash2, + TriangleAlert, +} from 'lucide-react' +import { ApiError } from '@/api/client' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { EmptyState } from '@/components/empty-state' +import { GithubMark } from '@/components/github-mark' +import { + Panel, + PanelContent, + PanelDescription, + PanelHeader, + PanelTitle, +} from '@/components/panel' +import { StatusBadge } from '@/components/status-badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Field, Input, Select, Textarea } from '@/components/form' +import { notifyError, notifySuccess } from '@/lib/errors' +import { cn } from '@/lib/utils' +import { + connectProjectGithub, + createProjectGithubManifest, + deleteProjectGithubApp, + detachProjectGithub, + getProjectGithub, + getProjectGithubRepositories, + putProjectGithubRepositories, + setProjectGithubAppPrivateKey, + setProjectGithubAppWebhookSecret, + type ProjectGithubEnvironment, + type ProjectGithubRepositories, + type ProjectGithubSelectedRepository, + type ProjectGithubStatus, +} from './api' +import { + defaultProjectGithubScopeMode, + isNarrowingProjectGithubScope, + projectGithubQueryKey, + projectGithubRepositoriesQueryKey, + projectGithubScopeCandidates, + sameProjectGithubScopePolicy, + selectedProjectGithubRepoIds, + toggleProjectGithubScopeRepository, + type ProjectGithubScopePolicy, +} from './github-scope' + +type Environment = 'development' | 'production' + +const BOTH_ENVIRONMENTS: Environment[] = ['development', 'production'] +const NEW_INSTALLATION = '__new__' + +function githubErrorCode(error: unknown): string | undefined { + if (!(error instanceof ApiError)) return undefined + const code = error.details?.code + return typeof code === 'string' ? code : error.type +} + +/** GitHub's app-manifest flow requires a browser form POST with a single + * `manifest` field; submit it into a new tab without leaving this page. */ +function postManifestToGithub( + action: string, + manifest: Record, +) { + const form = document.createElement('form') + form.method = 'post' + form.action = action + form.target = '_blank' + const field = document.createElement('input') + field.type = 'hidden' + field.name = 'manifest' + field.value = JSON.stringify(manifest) + form.appendChild(field) + document.body.appendChild(form) + form.submit() + form.remove() +} + +function GithubWizardSteps({ + current, + steps, +}: { + current: number + steps: string[] +}) { + return ( +
    + {steps.map((label, index) => ( +
  1. + + {index < current ? : index + 1} + + + {label} + + {index < steps.length - 1 ? ( + + ) : null} +
  2. + ))} +
+ ) +} + +/** The tradeoffs of the shared OpenComputer app, stated instead of enforced. */ +function SharedAppNotice() { + return ( +
+ +

+ The shared OpenComputer app uses one bot identity across every attached + project, GitHub shows a single grant with no per-project breakdown, and + a single project's access cannot be revoked from GitHub's side — only + narrowed here. Create a dedicated app for production. +

+
+ ) +} + +function GithubWaiting({ message }: { message: string }) { + return ( +
+ +
+

Waiting for GitHub…

+

{message}

+
+
+ ) +} + +function EnvironmentLimitCheckbox({ + environment, + onlyCurrent, + onChange, +}: { + environment: Environment + onlyCurrent: boolean + onChange: (onlyCurrent: boolean) => void +}) { + return ( +
+ +

+ By default both development and production are connected. Each + environment keeps its own attachment and can be switched or disconnected + independently. +

+
+ ) +} + +function GithubConnected({ environment }: { environment: Environment }) { + return ( +
+ +
+

GitHub connected

+

+ The installation landed for {environment}. Review the repository scope + to control what agents can reach. +

+
+
+ ) +} + +function GithubConnectDialog({ + projectId, + environment, + status, + connected, + onOpenChange, + onAwaitGithub, + onCreateDedicated, +}: { + projectId: string + environment: Environment + status: ProjectGithubStatus + connected: boolean + onOpenChange: (open: boolean) => void + onAwaitGithub: () => void + onCreateDedicated: () => void +}) { + const queryClient = useQueryClient() + const installations = status.installations + const [installationId, setInstallationId] = useState( + installations[0]?.id ?? NEW_INSTALLATION, + ) + const [onlyCurrentEnv, setOnlyCurrentEnv] = useState(false) + const [waiting, setWaiting] = useState(false) + const chosen = installations.find( + (installation) => installation.id === installationId, + ) + const chosenAppMode = chosen ? (chosen.app?.mode ?? 'oc_app') : 'oc_app' + const sharedPath = chosenAppMode === 'oc_app' + const newInstallBlocked = !chosen && !status.ocAppAvailable + + const connect = useMutation({ + mutationFn: () => + connectProjectGithub({ + projectId, + environments: onlyCurrentEnv ? [environment] : BOTH_ENVIRONMENTS, + ...(chosen ? { installationId: chosen.id } : {}), + scopeMode: defaultProjectGithubScopeMode(chosenAppMode), + }), + onSuccess: async (result) => { + if ('installUrl' in result) { + window.open(result.installUrl, '_blank', 'noopener') + setWaiting(true) + onAwaitGithub() + return + } + await queryClient.invalidateQueries({ + queryKey: projectGithubQueryKey(projectId), + }) + await queryClient.invalidateQueries({ + queryKey: projectGithubRepositoriesQueryKey(projectId, environment), + }) + notifySuccess( + 'GitHub connected.', + 'Review the repository scope below to control what agents can reach.', + ) + onOpenChange(false) + }, + onError: (error) => notifyError("Couldn't connect GitHub.", error), + }) + + return ( + + + + Connect GitHub + + Agents reach GitHub through short-lived installation tokens scoped + to this project's repository selection — no personal access tokens. + + + {waiting ? ( +
+ {connected ? ( + + ) : ( + + )} + + + +
+ ) : ( +
+ {installations.length ? ( + + setOrganization(event.target.value)} + placeholder="my-org" + autoComplete="off" + /> + + + + + + + + ) : step === 'create' ? ( +
+

+ GitHub will show a pre-filled app manifest. The suggested name + comes from this project, but GitHub requires a globally unique + name and lets you edit it — whatever name you land on is accepted + here. The app stays private to its owner account. +

+ + + + +
+ ) : ( +
+ {connected ? ( + + ) : ( + + )} + + + +
+ )} + +
+ ) +} + +function DormantRepositoryLabel({ + repository, + unknownGrant, +}: { + repository: { granted: boolean; repoId: number } + unknownGrant: Set +}) { + if (unknownGrant.has(repository.repoId)) { + return ( + + Beyond the enumerated repositories — selection kept. + + ) + } + if (!repository.granted) { + return ( + + No longer granted on GitHub — kept, so access resumes if it is granted + again. + + ) + } + return null +} + +function GithubScopeEditor({ + projectId, + environment, +}: { + projectId: string + environment: Environment +}) { + const queryClient = useQueryClient() + const queryKey = projectGithubRepositoriesQueryKey(projectId, environment) + const repositories = useQuery({ + queryKey, + queryFn: () => getProjectGithubRepositories(projectId, environment), + }) + const [draft, setDraft] = useState() + const save = useMutation({ + mutationFn: (policy: ProjectGithubScopePolicy) => + putProjectGithubRepositories({ + projectId, + environment, + mode: policy.mode, + ...(policy.mode === 'selected' + ? { repositories: policy.repositories } + : {}), + }), + onSuccess: async () => { + setDraft(undefined) + await queryClient.invalidateQueries({ queryKey }) + await queryClient.invalidateQueries({ + queryKey: projectGithubQueryKey(projectId), + }) + notifySuccess( + 'Repository access updated.', + 'The next minted token uses this scope.', + ) + }, + onError: (error) => { + if (githubErrorCode(error) === 'repository_not_granted') { + notifyError( + 'A selected repository is no longer granted to the installation.', + error, + ) + return + } + notifyError("Couldn't update repository access.", error) + }, + }) + + const renderRetainedSelection = ( + selected: ProjectGithubSelectedRepository[], + ) => + selected.length ? ( +
+ {selected.map((repository) => ( +
+ + + {repository.fullName} + + {!repository.granted ? ( + + No longer granted + + ) : null} +
+ ))} +
+ ) : null + + return ( + + +
+ Repository access + + What agents in {environment} can reach through githubApp() + connections. Scope changes apply to the next minted token; + already-issued tokens can stay valid for up to an hour. + +
+ + {environment} + +
+ {repositories.isLoading ? ( + + Enumerating granted + repositories… + + ) : repositories.isError || repositories.data?.state === 'unavailable' ? ( + +
+ +
+

+ GitHub can't be reached right now +

+

+ This is a temporary GitHub failure. The connection and the saved + scope below are unchanged — nothing needs to be reconnected. +

+
+
+ {repositories.data?.state === 'unavailable' + ? renderRetainedSelection(repositories.data.selected) + : null} + +
+ ) : repositories.data?.state === 'connected' ? ( + save.mutate(policy)} + /> + ) : ( + + GitHub is not connected for {environment}. + + )} +
+ ) +} + +function GithubScopeForm({ + data, + draft, + setDraft, + pending, + onSave, +}: { + data: Extract + draft: ProjectGithubScopePolicy | undefined + setDraft: (policy: ProjectGithubScopePolicy | undefined) => void + pending: boolean + onSave: (policy: ProjectGithubScopePolicy) => void +}) { + const serverPolicy: ProjectGithubScopePolicy = + data.scopeMode === 'all' + ? { mode: 'all' } + : { + mode: 'selected', + repositories: data.selected.map(({ repoId, fullName }) => ({ + repoId, + fullName, + })), + } + const policy = draft ?? serverPolicy + const candidates = projectGithubScopeCandidates(data.grant, data.selected) + const selectedIds = new Set(selectedProjectGithubRepoIds(policy)) + const unknownGrant = new Set(data.unavailableSelected) + const dirty = !sameProjectGithubScopePolicy(policy, serverPolicy) + const narrowing = isNarrowingProjectGithubScope(serverPolicy, policy) + + return ( + +
+ + +
+ + {policy.mode === 'selected' ? ( +
+ {data.truncated ? ( +

+ Showing the first 500 repositories from GitHub. Repositories + beyond this limit are not listed, and selections among them are + kept — never treated as revoked. +

+ ) : null} +
+ {candidates.length ? ( + candidates.map((repository) => ( + + )) + ) : ( +

+ The installation has no granted repositories yet. Grant some on + GitHub, then retry. +

+ )} +
+ {selectedIds.size === 0 ? ( +

+ No repositories selected — GitHub access is off for this + environment while everything else keeps working. +

+ ) : null} +
+ ) : null} + +
+ + {dirty ? ( + + ) : null} + {dirty && narrowing ? ( +

+ Narrowing applies to the next minted token; tokens already issued + can stay valid for up to an hour. +

+ ) : null} +
+
+ ) +} + +function GithubConnectionCard({ + projectId, + environment, + envState, + status, + onSwitch, + onCreateDedicated, +}: { + projectId: string + environment: Environment + envState: ProjectGithubEnvironment + status: ProjectGithubStatus + onSwitch: () => void + onCreateDedicated: () => void +}) { + const queryClient = useQueryClient() + const app = envState.app + const installation = envState.installation + const fullApp = status.apps.find((candidate) => candidate.id === app?.id) + const htmlUrl = fullApp?.htmlUrl + const dedicated = app?.mode === 'dedicated' + const [confirmDetach, setConfirmDetach] = useState(false) + const [confirmDeleteApp, setConfirmDeleteApp] = useState(false) + const [secretOpen, setSecretOpen] = useState(false) + const [keyOpen, setKeyOpen] = useState(false) + const [webhookSecret, setWebhookSecret] = useState('') + const [privateKey, setPrivateKey] = useState('') + + const invalidate = async () => { + await queryClient.invalidateQueries({ + queryKey: projectGithubQueryKey(projectId), + }) + await queryClient.invalidateQueries({ + queryKey: projectGithubRepositoriesQueryKey(projectId, environment), + }) + } + const detach = useMutation({ + mutationFn: () => detachProjectGithub(projectId, environment), + onSuccess: async () => { + setConfirmDetach(false) + notifySuccess( + `GitHub disconnected from ${environment}.`, + 'Other environments and the GitHub installation are unaffected.', + ) + await invalidate() + }, + onError: (error) => notifyError("Couldn't disconnect GitHub.", error), + }) + const removeApp = useMutation({ + mutationFn: () => deleteProjectGithubApp(projectId, app!.id), + onSuccess: async () => { + setConfirmDeleteApp(false) + notifySuccess('GitHub app removed.') + await invalidate() + }, + onError: (error) => notifyError("Couldn't remove the GitHub app.", error), + }) + const saveWebhookSecret = useMutation({ + mutationFn: () => + setProjectGithubAppWebhookSecret(projectId, app!.id, webhookSecret), + onSuccess: async () => { + setSecretOpen(false) + setWebhookSecret('') + notifySuccess( + 'Webhook secret saved.', + 'Deliveries verify against the new secret from now on.', + ) + await invalidate() + }, + onError: (error) => notifyError("Couldn't save the webhook secret.", error), + }) + const savePrivateKey = useMutation({ + mutationFn: () => + setProjectGithubAppPrivateKey(projectId, app!.id, privateKey), + onSuccess: async () => { + setKeyOpen(false) + setPrivateKey('') + notifySuccess( + 'Private key saved.', + 'App authentication uses the new key from now on.', + ) + await invalidate() + }, + onError: (error) => notifyError("Couldn't save the private key.", error), + }) + + return ( + + +
+ GitHub connection + + The app and installation this environment mints repository-scoped + tokens against. + +
+ + {environment} + +
+ +
+
+ + + +
+

+ {app?.name ?? 'GitHub app'} +

+

+ {app?.mode === 'oc_app' + ? 'Shared OpenComputer app' + : 'Dedicated app'} + {app ? ` · @${app.slug}` : ''} + {installation + ? ` · installed on ${installation.accountLogin} (${installation.accountType})` + : ''} +

+

+ {envState.scopeMode === 'all' + ? 'Scope: all granted repositories (install-wide tokens)' + : `Scope: ${envState.selectedRepositoryCount ?? 0} selected ${ + (envState.selectedRepositoryCount ?? 0) === 1 + ? 'repository' + : 'repositories' + }`} +

+
+
+ {envState.state === 'connected' ? ( + + ) : envState.state === 'auth_required' ? ( + + ) : envState.state === 'app_suspended' ? ( + + ) : ( + + )} +
+ + {envState.state === 'auth_required' ? ( +
+ +

+ GitHub authorization needs attention: the app's private key is + invalid, or newly requested permissions have not been accepted on + GitHub yet. Review the app on GitHub + {dedicated ? ' or re-enter its private key below' : ''}; access + resumes automatically once GitHub accepts. +

+
+ ) : envState.state === 'app_suspended' ? ( +
+ +

+ This installation is suspended on GitHub, so no tokens can be + minted. Unsuspend it in the GitHub account's installed-apps + settings — nothing needs to change here, and access resumes + automatically. +

+
+ ) : envState.state === 'app_deleted' ? ( +
+ +

+ This dedicated app was deleted on GitHub, so its stored key can no + longer authenticate — this is different from uninstalling. Remove + the app here, then create a new dedicated app. +

+
+ ) : null} + + {app?.mode === 'oc_app' ? : null} + +
+ {htmlUrl ? ( + + ) : null} + + {envState.state === 'app_deleted' ? ( + + ) : null} + {dedicated ? ( + <> + + + + + ) : null} + +
+
+ + + + + Re-enter webhook secret + + GitHub offers no API to read or rotate a webhook secret. If you + changed it on GitHub, paste the new value so event deliveries + verify again. + + + + setWebhookSecret(event.target.value)} + placeholder="••••••••" + autoComplete="new-password" + /> + + + + + + + + + + + + Re-enter private key + + Generate a new private key in the app's GitHub settings, then + paste the PEM here. The key is stored encrypted and never shown + again. + + + +