From b4d8fd75d26c39fdb24f321765508539fd960d55 Mon Sep 17 00:00:00 2001 From: alpha nerd Date: Sat, 30 May 2026 10:56:09 +0200 Subject: [PATCH 1/5] fix: fail-close permission gate --- index.ts | 56 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 29b19f1..c495df3 100644 --- a/index.ts +++ b/index.ts @@ -196,8 +196,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 +218,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 +659,37 @@ async function assertPermissions() { console.log(`Asserting permissions for user ${actor}...`) - 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)") - } 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)") - 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)") + // 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 write access, i.e. be a collaborator/member. + // Forgejo: GET .../collaborators/{user} returns 204 if a collaborator, 404 if not. + // We use a raw fetch (not forgejoFetch) because the 204 response has no JSON body. + const { forgejoToken } = getForgejoConfig() + const url = forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor) + let res: Response + try { + res = await fetch(url, { headers: { Authorization: `token ${forgejoToken}` } }) + } catch (error: any) { + // Fail closed: if we cannot verify permissions, deny. + throw new Error(`Could not verify permissions for "${actor}": ${error.message}`) + } + + if (res.status === 204 || res.ok) { + console.log(" permission: write (collaborator)") + return + } + 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 ───────────────────────────────────────────────────────── From 9ce4dbf015181e6dc6803d0676c942e853cc5e2d Mon Sep 17 00:00:00 2001 From: alpha nerd Date: Tue, 2 Jun 2026 08:34:31 +0200 Subject: [PATCH 2/5] fix: correctly check permissions --- index.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index c495df3..88be5bc 100644 --- a/index.ts +++ b/index.ts @@ -665,11 +665,15 @@ async function assertPermissions() { return } - // Otherwise the actor must have write access, i.e. be a collaborator/member. - // Forgejo: GET .../collaborators/{user} returns 204 if a collaborator, 404 if not. - // We use a raw fetch (not forgejoFetch) because the 204 response has no JSON body. + // 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) + const url = forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor, "permission") let res: Response try { res = await fetch(url, { headers: { Authorization: `token ${forgejoToken}` } }) @@ -678,9 +682,16 @@ async function assertPermissions() { throw new Error(`Could not verify permissions for "${actor}": ${error.message}`) } - if (res.status === 204 || res.ok) { - console.log(" permission: write (collaborator)") - return + if (res.ok) { + const { permission } = (await res.json().catch(() => ({}))) as { permission?: string } + if (permission === "admin" || permission === "write") { + console.log(` permission: ${permission}`) + return + } + 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( From ccd418084eecd32d4c04718901229cfa2534bb8c Mon Sep 17 00:00:00 2001 From: alpha nerd Date: Tue, 2 Jun 2026 08:40:30 +0200 Subject: [PATCH 3/5] fix: add permission owner --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 88be5bc..fe0d48d 100644 --- a/index.ts +++ b/index.ts @@ -684,7 +684,7 @@ async function assertPermissions() { if (res.ok) { const { permission } = (await res.json().catch(() => ({}))) as { permission?: string } - if (permission === "admin" || permission === "write") { + if (permission === "owner" || permission === "admin" || permission === "write") { console.log(` permission: ${permission}`) return } From 343481332527ad854d58f1cd748047101a1322d2 Mon Sep 17 00:00:00 2001 From: alpha nerd Date: Mon, 8 Jun 2026 11:21:52 +0200 Subject: [PATCH 4/5] feat: extend logging for chatData --- index.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index fe0d48d..918d49c 100644 --- a/index.ts +++ b/index.ts @@ -955,19 +955,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 ──────────────────────────────────────────────────────── From 1919111a589ac58467c2d4c008fe60a2556225da Mon Sep 17 00:00:00 2001 From: alpha nerd Date: Mon, 8 Jun 2026 11:34:15 +0200 Subject: [PATCH 5/5] feat: trim secrects that accidentally contain illegal trailing chars --- index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 918d49c..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') }