Compare commits

...
Sign in to create a new pull request.

5 commits

View file

@ -147,13 +147,20 @@ type PromptFiles = PromptFile[]
// ─── Auth config ───────────────────────────────────────────────────────────── // ─── Auth config ─────────────────────────────────────────────────────────────
async function createAuthConfig(): Promise<string> { async function createAuthConfig(): Promise<string> {
const nomyoApiKey = process.env["NOMYO_API_KEY"] // Trim surrounding whitespace/newlines: a stray "\n" in the stored secret
const nomyoApiUrl = process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api" // makes the "Authorization: Bearer <key>" 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"] const modelEnv = process.env["MODEL"]
if (!nomyoApiKey) { if (!nomyoApiKey) {
throw new Error('Environment variable "NOMYO_API_KEY" is not set') 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) { if (!modelEnv) {
throw new Error('Environment variable "MODEL" is not set') throw new Error('Environment variable "MODEL" is not set')
} }
@ -196,8 +203,10 @@ async function createAuthConfig(): Promise<string> {
try { try {
await createAuthConfig() await createAuthConfig()
// Strip Forgejo write credentials from opencode's env so its bash tool cannot reach them. // Strip 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"]) // 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 = {} const agentEnv: NodeJS.ProcessEnv = {}
for (const [k, v] of Object.entries(process.env)) { for (const [k, v] of Object.entries(process.env)) {
if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v
@ -216,9 +225,11 @@ try {
forgejoHost = new URL(forgejoApiUrl).hostname forgejoHost = new URL(forgejoApiUrl).hostname
accessToken = forgejoToken accessToken = forgejoToken
// Gate on permissions before doing any work (fetching prompt images, etc.).
await assertPermissions()
const { userPrompt, promptFiles } = await getUserPrompt() const { userPrompt, promptFiles } = await getUserPrompt()
await configureGitIdentity() await configureGitIdentity()
await assertPermissions()
const comment = await createComment() const comment = await createComment()
commentId = comment.id commentId = comment.id
@ -655,23 +666,48 @@ async function assertPermissions() {
console.log(`Asserting permissions for user ${actor}...`) console.log(`Asserting permissions for user ${actor}...`)
try { // The repo owner (and its bot account) is always allowed.
// Forgejo: check if user is a collaborator/member
await forgejoFetch<any>(
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]`) { if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) {
console.log(" permission: admin (owner)") console.log(" permission: admin (owner)")
return return
} }
console.error(`Failed to check permissions: ${error.message}`)
// In Actions context, if we can write to the repo, we have write access // Otherwise the actor must have at least write access. We query the effective
// We'll assume write access since the workflow has the right permissions // permission endpoint rather than the bare collaborators endpoint, because the
console.log(" permission: write (assumed from workflow permissions)") // 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 {
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.ok) {
const { permission } = (await res.json().catch(() => ({}))) as { permission?: string }
if (permission === "owner" || 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(
`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 ───────────────────────────────────────────────────────── // ─── Git operations ─────────────────────────────────────────────────────────
@ -926,19 +962,35 @@ async function chat(text: string, files: PromptFiles = []) {
if (!chatRes.ok) { if (!chatRes.ok) {
throw new Error(`opencode /chat returned ${chatRes.status} ${chatRes.statusText}: ${rawText.slice(0, 1000)}`) 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 { try {
chatData = JSON.parse(rawText) chatData = JSON.parse(rawText)
} catch (e) { } catch (e) {
throw new Error(`opencode /chat returned non-JSON (status ${chatRes.status}, content-type ${chatRes.headers.get("content-type")}): ${rawText.slice(0, 1000)}`) 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 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 ──────────────────────────────────────────────────────── // ─── Prompt building ────────────────────────────────────────────────────────