diff --git a/index.ts b/index.ts index 29b19f1..8a0fb4a 100644 --- a/index.ts +++ b/index.ts @@ -147,13 +147,20 @@ type PromptFiles = PromptFile[] // ─── Auth config ───────────────────────────────────────────────────────────── async function createAuthConfig(): Promise { - const nomyoApiKey = process.env["NOMYO_API_KEY"] - const nomyoApiUrl = process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api" + // Trim surrounding whitespace/newlines: a stray "\n" in the stored secret + // makes the "Authorization: Bearer " header an invalid HTTP header value. + const nomyoApiKey = process.env["NOMYO_API_KEY"]?.trim() + const nomyoApiUrl = (process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api").trim() const modelEnv = process.env["MODEL"] if (!nomyoApiKey) { throw new Error('Environment variable "NOMYO_API_KEY" is not set') } + // Reject any remaining character that is illegal in an HTTP header value + // (control chars / non-ASCII) before it reaches opencode's Authorization header. + if (/[^\x20-\x7E]/.test(nomyoApiKey)) { + throw new Error('NOMYO_API_KEY contains characters that are invalid in an HTTP header (control or non-ASCII). Check the secret for stray whitespace or hidden characters.') + } if (!modelEnv) { throw new Error('Environment variable "MODEL" is not set') } @@ -196,8 +203,10 @@ async function createAuthConfig(): Promise { try { await createAuthConfig() - // Strip Forgejo write credentials from opencode's env so its bash tool cannot reach them. - const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN"]) + // Strip credentials from opencode's env so its bash tool cannot reach them. + // NOMYO_API_KEY is handed to the server via OPENCODE_AUTH_CONTENT, so the raw + // env var is not needed by the agent and is removed to limit exfiltration. + const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN", "NOMYO_API_KEY"]) const agentEnv: NodeJS.ProcessEnv = {} for (const [k, v] of Object.entries(process.env)) { if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v @@ -216,9 +225,11 @@ try { forgejoHost = new URL(forgejoApiUrl).hostname accessToken = forgejoToken + // Gate on permissions before doing any work (fetching prompt images, etc.). + await assertPermissions() + const { userPrompt, promptFiles } = await getUserPrompt() await configureGitIdentity() - await assertPermissions() const comment = await createComment() commentId = comment.id @@ -655,23 +666,48 @@ async function assertPermissions() { console.log(`Asserting permissions for user ${actor}...`) + // The repo owner (and its bot account) is always allowed. + if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) { + console.log(" permission: admin (owner)") + return + } + + // Otherwise the actor must have at least write access. We query the effective + // permission endpoint rather than the bare collaborators endpoint, because the + // latter only lists *direct* collaborators and 404s for users who inherit + // access through an organization team (e.g. the "owners" team). + // Forgejo: GET .../collaborators/{user}/permission returns + // { permission: "admin" | "write" | "read" | "none", ... } + // accounting for direct collaboration, team membership, and org ownership. + const { forgejoToken } = getForgejoConfig() + const url = forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor, "permission") + let res: Response try { - // Forgejo: check if user is a collaborator/member - await forgejoFetch( - forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor), - ) - console.log(" permission: write (collaborator)") + res = await fetch(url, { headers: { Authorization: `token ${forgejoToken}` } }) } catch (error: any) { - // If not a collaborator, check if actor is the repo owner (via GITHUB_ACTOR) - if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) { - console.log(" permission: admin (owner)") + // Fail closed: if we cannot verify permissions, deny. + throw new Error(`Could not verify permissions for "${actor}": ${error.message}`) + } + + if (res.ok) { + const { permission } = (await res.json().catch(() => ({}))) as { permission?: string } + if (permission === "owner" || permission === "admin" || permission === "write") { + console.log(` permission: ${permission}`) return } - console.error(`Failed to check permissions: ${error.message}`) - // In Actions context, if we can write to the repo, we have write access - // We'll assume write access since the workflow has the right permissions - console.log(" permission: write (assumed from workflow permissions)") + throw new Error( + `User "${actor}" is not authorized to trigger this action ` + + `(requires write access to ${context.repo.owner}/${context.repo.repo}, has "${permission ?? "none"}").`, + ) } + if (res.status === 404) { + throw new Error( + `User "${actor}" is not authorized to trigger this action ` + + `(requires write access to ${context.repo.owner}/${context.repo.repo}).`, + ) + } + const text = await res.text().catch(() => "") + throw new Error(`Could not verify permissions for "${actor}": ${res.status} ${res.statusText} ${text}`) } // ─── Git operations ───────────────────────────────────────────────────────── @@ -926,19 +962,35 @@ async function chat(text: string, files: PromptFiles = []) { if (!chatRes.ok) { throw new Error(`opencode /chat returned ${chatRes.status} ${chatRes.statusText}: ${rawText.slice(0, 1000)}`) } - let chatData: { parts?: unknown[]; data?: { parts?: unknown[] } } + let chatData: any try { chatData = JSON.parse(rawText) } catch (e) { throw new Error(`opencode /chat returned non-JSON (status ${chatRes.status}, content-type ${chatRes.headers.get("content-type")}): ${rawText.slice(0, 1000)}`) } - // Find the last text part in the response + const info = chatData?.info ?? chatData?.data?.info const parts = chatData?.parts || chatData?.data?.parts || [] - const match = parts.findLast((p: any) => p.type === "text") as { text: string } | undefined - if (!match) throw new Error("Failed to parse the text response") - return match.text + // Surface a provider/model error instead of the generic parse failure. + if (info?.error) { + throw new Error(`opencode model error: ${JSON.stringify(info.error).slice(0, 2000)}`) + } + + // Prefer the last non-empty text part. + const textParts = parts.filter((p: any) => p.type === "text" && p.text?.trim()) + const last = textParts[textParts.length - 1] as { text: string } | undefined + if (last) return last.text + + // The agent may have done work (edited files) without a closing text message. + // Don't hard-fail in that case; report what happened instead. + console.error("No text part. Part types:", parts.map((p: any) => p.type).join(", ") || "none") + const ranTools = parts.some((p: any) => p.type === "tool") + if (ranTools) return "_(opencode completed the run but produced no summary message.)_" + + throw new Error( + `Failed to parse the text response (parts: ${parts.map((p: any) => p.type).join(", ") || "none"}; raw: ${rawText.slice(0, 1000)})`, + ) } // ─── Prompt building ────────────────────────────────────────────────────────