From 601532a9322c95e1bebe814b4aa4610296ec9aaf Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Thu, 20 Aug 2026 17:24:23 +0100 Subject: [PATCH 1/4] feat(agent,cli,api-edge): githubApp() connection credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public half of the GitHub App integration (serverless-agents-ws work 010): - @opencomputer/agent: githubApp({ permissions }) credential reference for defineConnection headers. Permissions are required, validated against the supported key set (contents, pull_requests, issues, metadata, checks) with read-only keys enforced; valid only on GitHub API origins. The connection headers type widens to include the reference. - CLI: the static connection-header parser accepts githubApp() with an inline object-literal permissions argument (variables/spreads are rejected with a targeted error), the bundled authoring shim gains the same helper, and the compiled manifest carries {kind: "github_app", permissions}. The secrets origin inference and secret-set flows now skip non-secret header kinds. New commands: `opencomputer github status|connect`. `opencomputer dev` warns once when compiled code uses githubApp() but the development environment has no GitHub connection, pointing at the connect flow. - api-edge: explicit allowlist entries for the project GitHub management routes, and an unauthenticated public surface for the two browser callbacks and the App webhook ingress, forwarded to the private edge which authorizes them by one-time state / HMAC. The public origin is what gets baked into GitHub app manifests — never the private hostname. Compiler tests cover the literal-permissions manifest output and the non-literal rejection; agent/cli/api-edge type-checks and suites green. Co-Authored-By: Claude Fable 5 --- agent/src/index.ts | 70 ++++++++++- cli/src/api.ts | 33 ++++++ cli/src/commands.ts | 74 +++++++++++- cli/src/dev.ts | 29 ++++- cli/src/index.ts | 2 + cli/src/project.test.ts | 75 ++++++++++++ cli/src/project.ts | 71 ++++++++++- cloudflare-workers/api-edge/src/index.ts | 7 ++ .../api-edge/src/managed_agents.ts | 110 ++++++++++++++++++ 9 files changed, 465 insertions(+), 6 deletions(-) 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, From 75ca410dd0c4ae247d435ed5007c530dc4c2c04b Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Thu, 20 Aug 2026 17:27:54 +0100 Subject: [PATCH 2/4] dev(web): OC_MANAGED_TARGET proxy bypass for local managed-agents testing Mirrors the OC_V3_KEY pattern: with OC_MANAGED_TARGET set, Vite forwards /api/managed-agents/* to a local `wrangler dev` of the private edge as /v1/*, which in development mode accepts unauthenticated requests as the local account. GitHub browser callbacks ride the same rewrite, so the dedicated-app manifest flow round-trips through real GitHub against localhost. Co-Authored-By: Claude Fable 5 --- web/vite.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/vite.config.ts b/web/vite.config.ts index 6ef4d4d09..851ff0383 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -14,6 +14,24 @@ const target = process.env.OC_API_TARGET || 'http://localhost:8080' // org-token; this shortcut is local-only. const v3Key = process.env.OC_V3_KEY const v3Target = process.env.OC_V3_TARGET || 'https://api.opencomputer.dev' + +// Dev-only managed-agents bypass. With OC_MANAGED_TARGET set (e.g. a local +// `wrangler dev` of the private edge on http://localhost:8787), Vite forwards +// /api/managed-agents/* straight to it as /v1/* — skipping the public api-edge +// adapter. The private edge in development mode accepts unauthenticated +// requests as the local account, so the dashboard works with no key. GitHub +// browser callbacks (/api/managed-agents/github/setup, .../manifest/callback) +// ride the same rewrite. +const managedTarget = process.env.OC_MANAGED_TARGET +const managedProxy: Record = managedTarget + ? { + '/api/managed-agents': { + target: managedTarget, + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/managed-agents/, '/v1'), + }, + } + : {} const injectKey: ProxyOptions['configure'] = (proxy) => { proxy.on('proxyReq', (proxyReq) => { if (v3Key) proxyReq.setHeader('x-api-key', v3Key) @@ -52,6 +70,7 @@ export default defineConfig({ port: 3000, proxy: { ...v3Proxy, + ...managedProxy, '/auth': target, // Trailing slash so the SPA route `/api-keys` isn't proxied to the // backend; all real API paths live under `/api/dashboard/`. From 5020190bc65fe10e1cd03d8b885c6dd2c2d3eff8 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Thu, 20 Aug 2026 17:30:03 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat(web):=20Repositories=20tab=20=E2=80=94?= =?UTF-8?q?=20GitHub=20App=20connections=20and=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-level dashboard surface for the GitHub App integration (work 010 J1–J4): per-environment connection status with the full state model (not_connected / connected / auth_required / app_suspended / app_deleted / unavailable — the last renders a Retry with retained selections, never a reconnect prompt or an empty grant), the shared-OC-app install flow, an attach-existing installation picker, the dedicated-app manifest wizard (SlackWizard step pattern; form-POSTs the manifest to GitHub in a new tab and detects completion as a transition from a baselined attachment), and the all/selected scope editor with dormant no-longer-granted selections visibly retained, truncation warned once, and mode semantics (install-wide token vs per-id mint) stated inline. Scope logic re-derived namespaced in github-scope.ts rather than importing the other product's repository-access module. App management: webhook-secret and private-key re-entry, delete, Configure-on-GitHub deep link. Typecheck, build, and 189 web tests green; scope logic unit-tested (dormant retention, truncation-omission never classified as revoked). Co-Authored-By: Claude Fable 5 --- web/src/components/app-shell-nav.ts | 6 + web/src/components/app-shell.test.ts | 1 + web/src/managed-agents/Detail.tsx | 13 + web/src/managed-agents/Repositories.tsx | 1326 +++++++++++++++++++ web/src/managed-agents/api.ts | 254 ++++ web/src/managed-agents/github-scope.test.ts | 141 ++ web/src/managed-agents/github-scope.ts | 122 ++ 7 files changed, 1863 insertions(+) create mode 100644 web/src/managed-agents/Repositories.tsx create mode 100644 web/src/managed-agents/github-scope.test.ts create mode 100644 web/src/managed-agents/github-scope.ts 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. + + + +