diff --git a/bun.lock b/bun.lock index bd340ea6e5e6..45ebb1270d27 100644 --- a/bun.lock +++ b/bun.lock @@ -259,6 +259,21 @@ "typescript": "catalog:", }, }, + "packages/ghes-exchange": { + "name": "@opencode-ai/ghes-exchange", + "version": "0.0.1", + "dependencies": { + "@octokit/auth-app": "8.0.1", + "@octokit/rest": "catalog:", + "hono": "catalog:", + "jose": "6.0.11", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/opencode": { "name": "opencode", "version": "1.2.6", @@ -1282,6 +1297,8 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/ghes-exchange": ["@opencode-ai/ghes-exchange@workspace:packages/ghes-exchange"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], diff --git a/github/action.yml b/github/action.yml index 8652bb8c1517..39b567ae3dfc 100644 --- a/github/action.yml +++ b/github/action.yml @@ -72,3 +72,4 @@ runs: USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} MENTIONS: ${{ inputs.mentions }} OIDC_BASE_URL: ${{ inputs.oidc_base_url }} + GHES_APP_TOKEN: ${{ env.GHES_APP_TOKEN }} diff --git a/github/index.ts b/github/index.ts index da310178a7dc..b4594e3fa4b4 100644 --- a/github/index.ts +++ b/github/index.ts @@ -112,6 +112,18 @@ type IssueQueryResponse = { } } +function getGitHubURLs() { + const serverUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "") + const apiUrl = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "") + const host = new URL(serverUrl).host + return { serverUrl, apiUrl, host } +} + +function getNoreplyEmail(username: string, host: string) { + return `${username}@users.noreply.${host}` +} + +const ghUrls = getGitHubURLs() const { client, server } = createOpencode() let accessToken: string let octoRest: Octokit @@ -129,8 +141,9 @@ try { await assertOpencodeConnected() accessToken = await getAccessToken() - octoRest = new Octokit({ auth: accessToken }) + octoRest = new Octokit({ auth: accessToken, baseUrl: ghUrls.apiUrl }) octoGraph = graphql.defaults({ + baseUrl: ghUrls.apiUrl, headers: { authorization: `token ${accessToken}` }, }) @@ -368,6 +381,9 @@ function useShareUrl() { async function getAccessToken() { const { repo } = useContext() + const ghesAppToken = process.env["GHES_APP_TOKEN"] + if (ghesAppToken) return ghesAppToken + const envToken = useEnvGithubToken() if (envToken) return envToken @@ -446,8 +462,9 @@ async function getUserPrompt() { // ie. Image // ie. [api.json](https://github.com/user-attachments/files/21433810/api.json) // ie. ![Image](https://github.com/user-attachments/assets/xxxx) - const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi) - const tagMatches = prompt.matchAll(//gi) + const hostPattern = ghUrls.host.replace(/\./g, "\\.") + const mdMatches = prompt.matchAll(new RegExp(`!?\\[.*?\\]\\((https:\\/\\/${hostPattern}\\/user-attachments\\/[^)]+)\\)`, "gi")) + const tagMatches = prompt.matchAll(new RegExp(``, "gi")) const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index) console.log("Images", JSON.stringify(matches, null, 2)) @@ -655,7 +672,7 @@ async function configureGit(appToken: string) { if (isMock()) return console.log("Configuring git...") - const config = "http.https://github.com/.extraheader" + const config = `http.${ghUrls.serverUrl}/.extraheader` const ret = await $`git config --local --get ${config}` gitConfig = ret.stdout.toString().trim() @@ -664,13 +681,13 @@ async function configureGit(appToken: string) { await $`git config --local --unset-all ${config}` await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` await $`git config --global user.name "opencode-agent[bot]"` - await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"` + await $`git config --global user.email "${getNoreplyEmail("opencode-agent[bot]", ghUrls.host)}"` } async function restoreGitConfig() { if (gitConfig === undefined) return console.log("Restoring git config...") - const config = "http.https://github.com/.extraheader" + const config = `http.${ghUrls.serverUrl}/.extraheader` await $`git config --local ${config} "${gitConfig}"` } @@ -698,7 +715,7 @@ async function checkoutForkBranch(pr: GitHubPullRequest) { const localBranch = generateBranchName("pr") const depth = Math.max(pr.commits.totalCount, 20) - await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git` + await $`git remote add fork ${ghUrls.serverUrl}/${pr.headRepository.nameWithOwner}.git` await $`git fetch fork --depth=${depth} ${remoteBranch}` await $`git checkout -b ${localBranch} fork/${remoteBranch}` } @@ -720,7 +737,7 @@ async function pushToNewBranch(summary: string, branch: string) { await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"` await $`git push -u origin ${branch}` } @@ -731,7 +748,7 @@ async function pushToLocalBranch(summary: string) { await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"` await $`git push` } @@ -744,7 +761,7 @@ async function pushToForkBranch(summary: string, pr: GitHubPullRequest) { await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"` await $`git push fork HEAD:${remoteBranch}` } @@ -1041,7 +1058,7 @@ async function revokeAppToken() { if (!accessToken) return console.log("Revoking app token...") - await fetch("https://api.github.com/installation/token", { + await fetch(`${ghUrls.apiUrl}/installation/token`, { method: "DELETE", headers: { Authorization: `Bearer ${accessToken}`, diff --git a/packages/ghes-exchange/Dockerfile b/packages/ghes-exchange/Dockerfile new file mode 100644 index 000000000000..191a640aedb8 --- /dev/null +++ b/packages/ghes-exchange/Dockerfile @@ -0,0 +1,17 @@ +FROM oven/bun:1.3 AS base +WORKDIR /app + +FROM base AS install +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production + +FROM base AS release +COPY --from=install /app/node_modules node_modules +COPY src src +COPY package.json . + +ENV PORT=3000 +EXPOSE 3000 + +USER bun +ENTRYPOINT ["bun", "run", "src/index.ts"] diff --git a/packages/ghes-exchange/package.json b/packages/ghes-exchange/package.json new file mode 100644 index 000000000000..0385e8cc73ac --- /dev/null +++ b/packages/ghes-exchange/package.json @@ -0,0 +1,23 @@ +{ + "name": "@opencode-ai/ghes-exchange", + "version": "0.0.1", + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "dev": "bun run src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@octokit/auth-app": "8.0.1", + "@octokit/rest": "catalog:", + "hono": "catalog:", + "jose": "6.0.11" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ghes-exchange/src/exchange.ts b/packages/ghes-exchange/src/exchange.ts new file mode 100644 index 000000000000..a9657e5c2ca9 --- /dev/null +++ b/packages/ghes-exchange/src/exchange.ts @@ -0,0 +1,57 @@ +import { createRemoteJWKSet, jwtVerify } from "jose" +import { createAppAuth } from "@octokit/auth-app" +import { Octokit } from "@octokit/rest" + +export interface ExchangeConfig { + ghesHost: string + appId: string + appPrivateKey: string +} + +export async function exchangeToken(oidcToken: string, config: ExchangeConfig): Promise { + const ghesUrl = `https://${config.ghesHost}` + const issuer = `${ghesUrl}/_services/token` + const jwksUrl = `${issuer}/.well-known/jwks` + const audience = "opencode-github-action" + + // Verify OIDC token against GHES JWKS + const JWKS = createRemoteJWKSet(new URL(jwksUrl)) + let owner: string + let repo: string + + try { + const { payload } = await jwtVerify(oidcToken, JWKS, { + issuer, + audience, + }) + // sub format: 'repo:my-org/my-repo:ref:refs/heads/main' + const sub = payload.sub + if (!sub) throw new Error("Token missing sub claim") + const repoPart = sub.split(":")[1] + if (!repoPart) throw new Error("Token sub claim has unexpected format") + const parts = repoPart.split("/") + owner = parts[0]! + repo = parts[1]! + } catch (err) { + console.error("Token verification failed:", err) + throw new Error("Invalid or expired token") + } + + // Create app JWT and get installation token + const auth = createAppAuth({ + appId: config.appId, + privateKey: config.appPrivateKey, + }) + const appAuth = await auth({ type: "app" }) + + const apiUrl = `${ghesUrl}/api/v3` + const octokit = new Octokit({ auth: appAuth.token, baseUrl: apiUrl }) + const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo }) + + const installationAuth = await auth({ + type: "installation", + installationId: installation.id, + }) + + return installationAuth.token +} diff --git a/packages/ghes-exchange/src/index.ts b/packages/ghes-exchange/src/index.ts new file mode 100644 index 000000000000..d27ccfb6f5a9 --- /dev/null +++ b/packages/ghes-exchange/src/index.ts @@ -0,0 +1,113 @@ +import { Hono } from "hono" +import { exchangeToken, type ExchangeConfig } from "./exchange" +import { + getStoredConfig, + setStoredConfig, + exchangeManifestCode, + renderLandingPage, + renderSuccessPage, + renderErrorPage, +} from "./setup" + +const app = new Hono() + +function getGhesHost(): string { + const ghesHost = process.env["GHES_HOST"] + if (!ghesHost) throw new Error("GHES_HOST environment variable is required") + return ghesHost +} + +function getConfig(): ExchangeConfig | null { + const ghesHost = process.env["GHES_HOST"] + const appId = process.env["GHES_APP_ID"] + const appPrivateKey = process.env["GHES_APP_PRIVATE_KEY"] + + if (ghesHost && appId && appPrivateKey) { + return { ghesHost, appId, appPrivateKey } + } + + // Fall back to in-memory config from manifest flow + return getStoredConfig() +} + +function getRouteHost(c: { req: { header: (name: string) => string | undefined } }): string { + return c.req.header("X-Forwarded-Host") || c.req.header("Host") || "localhost:3000" +} + +app.get("/", (c) => { + const ghesHost = getGhesHost() + const routeHost = getRouteHost(c) + const config = getConfig() + const configured = config !== null + const appId = config?.appId || process.env["GHES_APP_ID"] + + return c.html(renderLandingPage(ghesHost, routeHost, configured, appId)) +}) + +app.get("/setup/callback", async (c) => { + const code = c.req.query("code") + if (!code) { + return c.html(renderErrorPage("No code parameter received from GitHub."), 400) + } + + try { + const ghesHost = getGhesHost() + const result = await exchangeManifestCode(ghesHost, code) + + // Store config in-memory for immediate use + setStoredConfig({ + ghesHost, + appId: String(result.id), + appPrivateKey: result.pem, + }) + + return c.html(renderSuccessPage(result, ghesHost)) + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error" + console.error("Manifest code exchange failed:", message) + return c.html(renderErrorPage(message), 500) + } +}) + +app.get("/health", (c) => { + const config = getConfig() + return c.json({ status: "ok", configured: config !== null }) +}) + +app.post("/exchange_github_app_token", async (c) => { + const token = c.req.header("Authorization")?.replace(/^Bearer /, "") + if (!token) { + return c.json({ error: "Authorization header is required" }, { status: 401 }) + } + + const config = getConfig() + if (!config) { + return c.json( + { error: "Exchange server is not configured. Visit the root URL to set up a GitHub App." }, + { status: 503 }, + ) + } + + try { + const installationToken = await exchangeToken(token, config) + return c.json({ token: installationToken }) + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error" + console.error("Token exchange failed:", message) + return c.json({ error: message }, { status: 403 }) + } +}) + +const port = parseInt(process.env["PORT"] || "3000", 10) + +export default { + port, + fetch: app.fetch, +} + +const config = getConfig() +if (config) { + console.log(`GHES exchange server listening on port ${port}`) +} else { + console.log(`GHES exchange server listening on port ${port} (setup mode — visit / to configure)`) +} diff --git a/packages/ghes-exchange/src/setup.ts b/packages/ghes-exchange/src/setup.ts new file mode 100644 index 000000000000..bf11d85d062a --- /dev/null +++ b/packages/ghes-exchange/src/setup.ts @@ -0,0 +1,208 @@ +export type AppManifestResult = { + id: number + slug: string + pem: string + webhook_secret: string + client_id: string + client_secret: string +} + +export interface AppConfig { + ghesHost: string + appId: string + appPrivateKey: string +} + +let storedConfig: AppConfig | null = null + +export function getStoredConfig(): AppConfig | null { + return storedConfig +} + +export function setStoredConfig(config: AppConfig) { + storedConfig = config +} + +export async function exchangeManifestCode(ghesHost: string, code: string): Promise { + const response = await fetch(`https://${ghesHost}/api/v3/app-manifests/${code}/conversions`, { + method: "POST", + headers: { Accept: "application/vnd.github+json" }, + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to create app: ${response.status} ${body}`) + } + + return (await response.json()) as AppManifestResult +} + +export function buildManifest(routeHost: string) { + return { + name: "ghes-exchange", + url: `https://${routeHost}`, + hook_attributes: { url: "https://example.com/placeholder" }, + redirect_url: `https://${routeHost}/setup/callback`, + public: false, + default_permissions: { + contents: "write", + pull_requests: "write", + issues: "write", + metadata: "read", + }, + default_events: ["issue_comment", "pull_request_review_comment"], + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +const STYLE = ` + body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #1a1a2e; color: #eee; } + .container { text-align: center; padding: 2rem; max-width: 700px; } + h1 { color: #60a5fa; margin-bottom: 1rem; } + p { color: #aaa; } + .btn { display: inline-block; padding: 0.75rem 1.5rem; background: #3b82f6; color: #fff; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer; text-decoration: none; } + .btn:hover { background: #2563eb; } + .status { margin-top: 1.5rem; padding: 1rem; background: rgba(74,222,128,0.1); border-radius: 0.5rem; text-align: left; } + .status dt { color: #4ade80; font-weight: bold; margin-top: 0.5rem; } + .status dd { color: #ccc; margin-left: 1rem; font-family: monospace; } + .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; } + textarea { width: 100%; height: 120px; background: #0f0f23; color: #ccc; border: 1px solid #333; border-radius: 0.5rem; padding: 0.5rem; font-family: monospace; font-size: 0.8rem; resize: vertical; } + code { background: #0f0f23; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-size: 0.85rem; } + pre { background: #0f0f23; padding: 1rem; border-radius: 0.5rem; text-align: left; overflow-x: auto; font-size: 0.85rem; } + a { color: #60a5fa; } +` + +export function renderLandingPage(ghesHost: string, routeHost: string, configured: boolean, appId?: string): string { + if (configured) { + return ` + + + GHES Exchange - Configured + + + +
+

GHES Exchange Server

+

The exchange server is configured and ready.

+
+
GHES Host
+
${escapeHtml(ghesHost)}
+
App ID
+
${escapeHtml(appId || "N/A")}
+
Status
+
Operational
+
+

+ POST /exchange_github_app_token with an OIDC Bearer token to get an installation token. +

+
+ +` + } + + const manifest = buildManifest(routeHost) + const manifestJson = JSON.stringify(manifest) + + return ` + + + GHES Exchange - Setup + + + +
+

GHES Exchange Server

+

No GitHub App is configured yet. Click the button below to create one on your GHES instance.

+

This will redirect you to ${escapeHtml(ghesHost)} to authorize the app creation.

+
+ + +
+

+ After creation, you will be redirected back here with the app credentials. +

+
+ +` +} + +export function renderSuccessPage(result: AppManifestResult, ghesHost: string): string { + return ` + + + GHES Exchange - App Created + + + +
+

GitHub App Created Successfully

+

The app has been created and the exchange server is now configured in-memory.

+
+
App ID
+
${result.id}
+
Slug
+
${escapeHtml(result.slug)}
+
Client ID
+
${escapeHtml(result.client_id)}
+
Client Secret
+
${escapeHtml(result.client_secret)}
+
Webhook Secret
+
${escapeHtml(result.webhook_secret)}
+
+ +

Private Key (PEM)

+ + +

Persist to Kubernetes (optional)

+

To survive pod restarts, store the credentials as K8s resources:

+
+# Update ConfigMap with the App ID
+oc patch configmap ghes-exchange-config -n arc-runners \\
+  --type merge -p '{"data":{"GHES_APP_ID":"${result.id}"}}'
+
+# Create or update the secret with the private key
+oc create secret generic opencode-ghes-secrets -n arc-runners \\
+  --from-literal=github-app-private-key="$(cat <pem-file>)" \\
+  --dry-run=client -o yaml | oc apply -f -
+
+# Restart the deployment to pick up changes
+oc rollout restart deployment/ghes-exchange -n arc-runners
+ +

+ Important: Save the private key now. It cannot be retrieved from GitHub again. +

+

+ View app on GHES +  |  + Back to status page +

+
+ +` +} + +export function renderErrorPage(error: string): string { + return ` + + + GHES Exchange - Error + + + +
+

Setup Error

+

An error occurred during GitHub App creation.

+
${escapeHtml(error)}
+

Back to setup page

+
+ +` +} diff --git a/packages/ghes-exchange/tsconfig.json b/packages/ghes-exchange/tsconfig.json new file mode 100644 index 000000000000..38c147fb536d --- /dev/null +++ b/packages/ghes-exchange/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index fd1a2f7e583f..e2679f05e29c 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -132,6 +132,83 @@ type IssueQueryResponse = { } } +type GHESAuthStrategy = "oidc" | "in-workflow" + +type AppManifestResult = { + id: number + slug: string + pem: string + webhook_secret: string + client_id: string + client_secret: string +} + +function buildManifestFormHTML(ghesUrl: string, manifestJson: string) { + return ` + + + OpenCode - Create GitHub App + + + +
+

Creating GitHub App...

+

Redirecting to ${ghesUrl}...

+
+
+ +
+ + +` +} + +const HTML_MANIFEST_SUCCESS = ` + + + OpenCode - GitHub App Created + + + +
+

GitHub App Created Successfully

+

You can close this window and return to the terminal.

+
+ + +` + +const HTML_MANIFEST_ERROR = (error: string) => ` + + + OpenCode - GitHub App Creation Failed + + + +
+

GitHub App Creation Failed

+

An error occurred.

+
${error}
+
+ +` + const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" @@ -159,6 +236,24 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string } return { owner: match[1], repo: match[2] } } +// Like parseGitHubRemote but matches any host (for GHES support) +export function parseGitRemote(url: string): { host: string; owner: string; repo: string } | null { + const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?([^/:]+)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) + if (!match) return null + return { host: match[1], owner: match[2], repo: match[3] } +} + +function getGitHubURLs() { + const serverUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "") + const apiUrl = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "") + const host = new URL(serverUrl).host + return { serverUrl, apiUrl, host } +} + +function getNoreplyEmail(username: string, host: string) { + return `${username}@users.noreply.${host}` +} + /** * Extracts displayable text from assistant response parts. * Returns null for non-text responses (signals summary needed). @@ -204,7 +299,7 @@ export const GithubInstallCommand = cmd({ UI.empty() prompts.intro("Install GitHub agent") const app = await getAppInfo() - await installGitHubApp() + const ghesStrategy = await installGitHubApp() const providers = await ModelsDev.get().then((p) => { // TODO: add guide for copilot, for now just hide it @@ -220,26 +315,50 @@ export const GithubInstallCommand = cmd({ printNextSteps() function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + const providerSecrets = + provider === "amazon-bedrock" + ? [ + " Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services", + ] + : [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ] + + let ghesSteps: string[] = [] + if (ghesStrategy === "in-workflow") { + ghesSteps = [ + "", + ` 3. Add GHES app secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + " - OPENCODE_GHES_APP_ID (the App ID printed above)", + " - OPENCODE_GHES_APP_PRIVATE_KEY (the private key from app creation)", + ] + } else if (ghesStrategy === "oidc") { + ghesSteps = [ + "", + ` 3. Deploy the GHES exchange server (packages/ghes-exchange)`, + "", + ` 4. Add secrets in org or repo (${app.owner}/${app.repo}) settings`, "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") + " - OPENCODE_OIDC_BASE_URL (URL of your deployed exchange server)", + ] } + const learnMoreStep = ghesStrategy + ? " Go to a GitHub issue and comment `/oc summarize` to see the agent in action" + : " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action" + prompts.outro( [ "Next steps:", "", ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, + ...providerSecrets, + ...ghesSteps, "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + learnMoreStep, "", " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", ].join("\n"), @@ -253,14 +372,15 @@ export const GithubInstallCommand = cmd({ throw new UI.CancelledError() } - // Get repo info + // Get repo info - use parseGitRemote to support any host (GHES) const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim() - const parsed = parseGitHubRemote(info) + const parsed = parseGitRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } + const isGHES = parsed.host !== "github.com" + return { owner: parsed.owner, repo: parsed.repo, host: parsed.host, root: Instance.worktree, isGHES } } async function promptProvider() { @@ -314,13 +434,36 @@ export const GithubInstallCommand = cmd({ return model } - async function installGitHubApp() { + async function installGitHubApp(): Promise { + if (app.isGHES) { + const strategy = await prompts.select({ + message: "Select authentication strategy for GHES", + options: [ + { + label: "In-workflow token generation", + value: "in-workflow" as const, + hint: "recommended - simpler setup", + }, + { + label: "Self-hosted OIDC exchange server", + value: "oidc" as const, + hint: "requires deploying an exchange service", + }, + ], + }) + + if (prompts.isCancel(strategy)) throw new UI.CancelledError() + + await createGHESApp() + return strategy + } + const s = prompts.spinner() s.start("Installing GitHub app") // Get installation const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") + if (installation) return s.stop("GitHub app already installed") as undefined // Open browser const url = "https://github.com/apps/opencode-agent" @@ -357,6 +500,7 @@ export const GithubInstallCommand = cmd({ } while (true) s.stop("Installed GitHub app") + return undefined async function getInstallation() { return await fetch( @@ -367,29 +511,182 @@ export const GithubInstallCommand = cmd({ } } + async function createGHESApp(): Promise { + const ghesUrl = `https://${app.host}` + const manifest = { + name: `opencode-agent-${app.owner}`, + url: "https://opencode.ai", + hook_attributes: { url: "https://example.com/placeholder" }, + redirect_url: "", // filled dynamically below + public: false, + default_permissions: { + contents: "write", + pull_requests: "write", + issues: "write", + metadata: "read", + }, + default_events: ["issue_comment", "pull_request_review_comment"], + } + + const s = prompts.spinner() + s.start("Creating GitHub App on your GHES instance") + + const result = await new Promise((resolve, reject) => { + let server: ReturnType + + const timeout = setTimeout(() => { + server.stop() + reject(new Error("Timed out waiting for GitHub App creation (5 minutes)")) + }, 5 * 60 * 1000) + + server = Bun.serve({ + port: 0, + fetch(req): Response | Promise { + const url = new URL(req.url) + + if (url.pathname === "/callback") { + const code = url.searchParams.get("code") + if (!code) { + return new Response(HTML_MANIFEST_ERROR("No code parameter received"), { + headers: { "Content-Type": "text/html" }, + }) + } + + // Exchange code for app credentials + ;(async () => { + try { + const response = await globalThis.fetch(`${ghesUrl}/api/v3/app-manifests/${code}/conversions`, { + method: "POST", + headers: { Accept: "application/vnd.github+json" }, + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to create app: ${response.status} ${body}`) + } + + const data = (await response.json()) as AppManifestResult + clearTimeout(timeout) + server.stop() + resolve(data) + } catch (err) { + clearTimeout(timeout) + server.stop() + reject(err) + } + })() + + return new Response(HTML_MANIFEST_SUCCESS, { + headers: { "Content-Type": "text/html" }, + }) + } + + // Serve the auto-POST form at root + const callbackUrl: string = `http://localhost:${server.port}/callback` + const manifestWithRedirect = { ...manifest, redirect_url: callbackUrl } + const manifestJson: string = JSON.stringify(manifestWithRedirect) + return new Response(buildManifestFormHTML(ghesUrl, manifestJson), { + headers: { "Content-Type": "text/html" }, + }) + }, + }) + + // Open browser + const localUrl = `http://localhost:${server.port}/` + const openCmd = + process.platform === "darwin" + ? `open "${localUrl}"` + : process.platform === "win32" + ? `start "" "${localUrl}"` + : `xdg-open "${localUrl}"` + + exec(openCmd, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${localUrl}`) + } + }) + }) + + s.stop("GitHub App created") + + prompts.log.info( + [ + "Save the following credentials as GitHub Actions secrets:", + "", + ` App ID: ${result.id}`, + ` App Slug: ${result.slug}`, + ` Client ID: ${result.client_id}`, + "", + " Private Key and Client Secret have been generated.", + " Store them securely - they cannot be retrieved again.", + ].join("\n"), + ) + + return result + } + async function addWorkflowFiles() { const envStr = provider === "amazon-bedrock" ? "" : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + const jobIf = ` if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode')` -on: + const triggers = `on: issue_comment: types: [created] pull_request_review_comment: - types: [created] + types: [created]` + + let workflowContent: string + + if (ghesStrategy === "in-workflow") { + // Option B: in-workflow token generation via actions/create-github-app-token + workflowContent = `name: opencode + +${triggers} jobs: opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') +${jobIf} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: \${{ secrets.OPENCODE_GHES_APP_ID }} + private-key: \${{ secrets.OPENCODE_GHES_APP_PRIVATE_KEY }} + + - name: Run opencode + uses: anomalyco/opencode/github@latest${envStr} + env: + GHES_APP_TOKEN: \${{ steps.app-token.outputs.token }} + with: + model: ${provider}/${model}` + } else if (ghesStrategy === "oidc") { + // Option A: self-hosted OIDC exchange server + workflowContent = `name: opencode + +${triggers} + +jobs: + opencode: +${jobIf} runs-on: ubuntu-latest permissions: id-token: write @@ -405,8 +702,36 @@ jobs: - name: Run opencode uses: anomalyco/opencode/github@latest${envStr} with: - model: ${provider}/${model}`, - ) + model: ${provider}/${model} + oidc_base_url: \${{ secrets.OPENCODE_OIDC_BASE_URL }}` + } else { + // github.com: standard OIDC flow + workflowContent = `name: opencode + +${triggers} + +jobs: + opencode: +${jobIf} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest${envStr} + with: + model: ${provider}/${model}` + } + + await Bun.write(path.join(app.root, WORKFLOW_FILE), workflowContent) prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } @@ -473,6 +798,7 @@ export const GithubRunCommand = cmd({ : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" + const ghUrls = getGitHubURLs() let appToken: string let octoRest: Octokit @@ -493,7 +819,10 @@ export const GithubRunCommand = cmd({ : undefined try { - if (useGithubToken) { + const ghesAppToken = process.env["GHES_APP_TOKEN"] + if (ghesAppToken) { + appToken = ghesAppToken + } else if (useGithubToken) { const githubToken = process.env["GITHUB_TOKEN"] if (!githubToken) { throw new Error( @@ -505,8 +834,9 @@ export const GithubRunCommand = cmd({ const actionToken = isMock ? args.token! : await getOidcToken() appToken = await exchangeForAppToken(actionToken) } - octoRest = new Octokit({ auth: appToken }) + octoRest = new Octokit({ auth: appToken, baseUrl: ghUrls.apiUrl }) octoGraph = graphql.defaults({ + baseUrl: ghUrls.apiUrl, headers: { authorization: `token ${appToken}` }, }) @@ -776,8 +1106,9 @@ export const GithubRunCommand = cmd({ // ie. Image // ie. [api.json](https://github.com/user-attachments/files/21433810/api.json) // ie. ![Image](https://github.com/user-attachments/assets/xxxx) - const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi) - const tagMatches = prompt.matchAll(//gi) + const hostPattern = ghUrls.host.replace(/\./g, "\\.") + const mdMatches = prompt.matchAll(new RegExp(`!?\\[.*?\\]\\((https:\\/\\/${hostPattern}\\/user-attachments\\/[^)]+)\\)`, "gi")) + const tagMatches = prompt.matchAll(new RegExp(``, "gi")) const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index) console.log("Images", JSON.stringify(matches, null, 2)) @@ -1019,7 +1350,7 @@ export const GithubRunCommand = cmd({ if (isMock) return console.log("Configuring git...") - const config = "http.https://github.com/.extraheader" + const config = `http.${ghUrls.serverUrl}/.extraheader` // actions/checkout@v6 no longer stores credentials in .git/config, // so this may not exist - use nothrow() to handle gracefully const ret = await $`git config --local --get ${config}`.nothrow() @@ -1032,12 +1363,12 @@ export const GithubRunCommand = cmd({ await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` await $`git config --global user.name "${AGENT_USERNAME}"` - await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"` + await $`git config --global user.email "${getNoreplyEmail(AGENT_USERNAME, ghUrls.host)}"` } async function restoreGitConfig() { if (gitConfig === undefined) return - const config = "http.https://github.com/.extraheader" + const config = `http.${ghUrls.serverUrl}/.extraheader` await $`git config --local ${config} "${gitConfig}"` } @@ -1065,7 +1396,7 @@ export const GithubRunCommand = cmd({ const localBranch = generateBranchName("pr") const depth = Math.max(pr.commits.totalCount, 20) - await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git` + await $`git remote add fork ${ghUrls.serverUrl}/${pr.headRepository.nameWithOwner}.git` await $`git fetch fork --depth=${depth} ${remoteBranch}` await $`git checkout -b ${localBranch} fork/${remoteBranch}` } @@ -1094,7 +1425,7 @@ export const GithubRunCommand = cmd({ } else { await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor!, ghUrls.host)}>"` } } await $`git push -u origin ${branch}` @@ -1106,7 +1437,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor!, ghUrls.host)}>"` } await $`git push` } @@ -1120,7 +1451,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${actor} <${getNoreplyEmail(actor!, ghUrls.host)}>"` } await $`git push fork HEAD:${remoteBranch}` } @@ -1550,7 +1881,7 @@ query($owner: String!, $repo: String!, $number: Int!) { async function revokeAppToken() { if (!appToken) return - await fetch("https://api.github.com/installation/token", { + await fetch(`${ghUrls.apiUrl}/installation/token`, { method: "DELETE", headers: { Authorization: `Bearer ${appToken}`, diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986ead..6f2676a380f5 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -1,5 +1,5 @@ -import { test, expect } from "bun:test" -import { parseGitHubRemote } from "../../src/cli/cmd/github" +import { test, expect, describe } from "bun:test" +import { parseGitHubRemote, parseGitRemote } from "../../src/cli/cmd/github" test("parses https URL with .git suffix", () => { expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) @@ -78,3 +78,81 @@ test("returns null for URLs with extra path segments", () => { expect(parseGitHubRemote("https://github.com/owner/repo/tree/main")).toBeNull() expect(parseGitHubRemote("https://github.com/owner/repo/blob/main/file.ts")).toBeNull() }) + +// parseGitRemote tests - matches any host for GHES support +describe("parseGitRemote", () => { + test("parses github.com https URL", () => { + expect(parseGitRemote("https://github.com/sst/opencode.git")).toEqual({ + host: "github.com", + owner: "sst", + repo: "opencode", + }) + }) + + test("parses github.com git@ URL", () => { + expect(parseGitRemote("git@github.com:sst/opencode.git")).toEqual({ + host: "github.com", + owner: "sst", + repo: "opencode", + }) + }) + + test("parses GHES https URL", () => { + expect(parseGitRemote("https://github.example.com/my-org/my-repo.git")).toEqual({ + host: "github.example.com", + owner: "my-org", + repo: "my-repo", + }) + }) + + test("parses GHES git@ URL", () => { + expect(parseGitRemote("git@github.example.com:my-org/my-repo.git")).toEqual({ + host: "github.example.com", + owner: "my-org", + repo: "my-repo", + }) + }) + + test("parses GHES ssh:// URL", () => { + expect(parseGitRemote("ssh://git@github.example.com/my-org/my-repo.git")).toEqual({ + host: "github.example.com", + owner: "my-org", + repo: "my-repo", + }) + }) + + test("parses GHES URL without .git suffix", () => { + expect(parseGitRemote("https://ghes.company.org/team/project")).toEqual({ + host: "ghes.company.org", + owner: "team", + repo: "project", + }) + }) + + test("parses gitlab URLs", () => { + expect(parseGitRemote("https://gitlab.com/owner/repo.git")).toEqual({ + host: "gitlab.com", + owner: "owner", + repo: "repo", + }) + }) + + test("returns null for invalid URLs", () => { + expect(parseGitRemote("not-a-url")).toBeNull() + expect(parseGitRemote("")).toBeNull() + expect(parseGitRemote("https://github.com/")).toBeNull() + expect(parseGitRemote("https://github.com/owner")).toBeNull() + }) + + test("returns null for URLs with extra path segments", () => { + expect(parseGitRemote("https://github.com/owner/repo/tree/main")).toBeNull() + }) + + test("parses repos with dots in the name", () => { + expect(parseGitRemote("https://github.example.com/socketio/socket.io.git")).toEqual({ + host: "github.example.com", + owner: "socketio", + repo: "socket.io", + }) + }) +})