Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
* @33Fraise33

/.github/** @33Fraise33
/.opencode/** @33Fraise33
/AGENTS.md @33Fraise33
/opencode.json @33Fraise33
48 changes: 48 additions & 0 deletions .github/scripts/opencode-auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { appendFileSync } from "node:fs"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"

const COMPONENTS = [
"OPENAI_OAUTH_ACCESS_TOKEN",
"OPENAI_OAUTH_REFRESH_TOKEN",
"OPENAI_OAUTH_EXPIRES",
"OPENAI_OAUTH_ACCOUNT_ID",
]

export function buildAuthContent(env) {
for (const name of COMPONENTS) {
if (typeof env[name] !== "string" || env[name].length === 0) {
throw new Error(`${name} is required.`)
}
}

if (!/^\d+$/.test(env.OPENAI_OAUTH_EXPIRES)) {
throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.")
}
const expires = Number(env.OPENAI_OAUTH_EXPIRES)
if (!Number.isSafeInteger(expires)) {
throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.")
}

return JSON.stringify({
openai: {
type: "oauth",
refresh: env.OPENAI_OAUTH_REFRESH_TOKEN,
access: env.OPENAI_OAUTH_ACCESS_TOKEN,
expires,
accountId: env.OPENAI_OAUTH_ACCOUNT_ID,
},
})
}

export function main(env = process.env, log = console.log) {
if (!env.GITHUB_ENV) throw new Error("GITHUB_ENV is required.")
const content = buildAuthContent(env)
const delimiter = `opencode_auth_${randomUUID()}`
log(`::add-mask::${content}`)
appendFileSync(env.GITHUB_ENV, `OPENCODE_AUTH_CONTENT<<${delimiter}\n${content}\n${delimiter}\n`)
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
49 changes: 49 additions & 0 deletions .github/scripts/opencode-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import test from "node:test"

import { buildAuthContent, main } from "./opencode-auth.mjs"

const components = {
OPENAI_OAUTH_ACCESS_TOKEN: "access-value",
OPENAI_OAUTH_REFRESH_TOKEN: "refresh-value",
OPENAI_OAUTH_EXPIRES: "123",
OPENAI_OAUTH_ACCOUNT_ID: "account-value",
}

test("constructs the exact OpenCode OAuth JSON", () => {
assert.deepEqual(JSON.parse(buildAuthContent(components)), {
openai: {
type: "oauth",
refresh: "refresh-value",
access: "access-value",
expires: 123,
accountId: "account-value",
},
})
})

test("requires every component and a non-negative safe integer expiration", () => {
for (const name of Object.keys(components)) {
assert.throws(() => buildAuthContent({ ...components, [name]: "" }), new RegExp(name))
}
for (const expires of ["-1", "1.2", "nope", "9007199254740992"]) {
assert.throws(() => buildAuthContent({ ...components, OPENAI_OAUTH_EXPIRES: expires }), /safe integer/)
}
})

test("masks JSON before writing it with a random delimiter", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-auth-"))
const envPath = path.join(directory, "env")
const logs = []
try {
main({ ...components, GITHUB_ENV: envPath }, (line) => logs.push(line))
const content = buildAuthContent(components)
assert.deepEqual(logs, [`::add-mask::${content}`])
assert.match(await readFile(envPath, "utf8"), /^OPENCODE_AUTH_CONTENT<<opencode_auth_[^\n]+\n.*\nopencode_auth_/)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
42 changes: 42 additions & 0 deletions .github/scripts/opencode-authorize-actor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs"
import { pathToFileURL } from "node:url"

export async function fetchActorPermission({ apiUrl, repository, username, token, fetchImpl = fetch }) {
if (!apiUrl || !repository || !username || !token) throw new Error("GitHub authorization configuration is incomplete.")
const response = await fetchImpl(`${apiUrl}/repos/${repository}/collaborators/${username}/permission`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
})
if (!response.ok) throw new Error(`GitHub collaborator permission lookup failed with HTTP ${response.status}.`)
return response.json()
}

export async function authorizeActor(event, options) {
const username = event.comment?.user?.login
if (options.mode === "plan" && username !== options.repositoryOwner) {
throw new Error("Plan mode is limited to the repository owner.")
}
const { permission } = await fetchActorPermission({ ...options, username })
if (!['admin', 'write'].includes(permission)) {
throw new Error("OpenCode requires repository write or admin permission.")
}
}

export async function main(env = process.env) {
if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.")
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"))
await authorizeActor(event, {
apiUrl: env.GITHUB_API_URL,
mode: env.MODE,
repository: env.GITHUB_REPOSITORY,
repositoryOwner: env.GITHUB_REPOSITORY_OWNER,
token: env.GITHUB_TOKEN,
})
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main()
}
40 changes: 40 additions & 0 deletions .github/scripts/opencode-authorize-actor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from "node:assert/strict"
import test from "node:test"

import { authorizeActor } from "./opencode-authorize-actor.mjs"

const event = { comment: { user: { login: "trusted-user" } } }
const options = {
apiUrl: "https://api.github.test",
mode: "build",
repository: "owner/repo",
repositoryOwner: "owner",
token: "token",
}

function response(permission, username = "trusted-user") {
return async (url) => ({
ok: true,
status: 200,
json: async () => {
assert.equal(url, `https://api.github.test/repos/owner/repo/collaborators/${username}/permission`)
return { permission }
},
})
}

test("authorizes only repository write and admin access", async () => {
await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("write") }))
await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("admin") }))
await assert.rejects(() => authorizeActor(event, { ...options, fetchImpl: response("read") }), /write or admin/)
})

test("limits plan mode to the repository owner", async () => {
await assert.rejects(
() => authorizeActor(event, { ...options, mode: "plan", repositoryOwner: "owner", fetchImpl: response("write") }),
/repository owner/,
)
await assert.doesNotReject(
() => authorizeActor({ comment: { user: { login: "owner" } } }, { ...options, mode: "plan", fetchImpl: response("write", "owner") }),
)
})
85 changes: 85 additions & 0 deletions .github/scripts/opencode-command.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { appendFileSync, readFileSync } from "node:fs"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"

export const MAX_PROMPT_LENGTH = 8000
const GITHUB_ATTACHMENT_URL = /https:\/\/github\.com\/user-attachments\//i

const MODELS = Object.freeze({
sol: "openai/gpt-5.6-sol",
terra: "openai/gpt-5.6-terra",
luna: "openai/gpt-5.6-luna",
})

const DEFAULT_MODELS = Object.freeze({
plan: MODELS.sol,
build: MODELS.terra,
})

const ALLOWED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"])

export function isAllowedAssociation(association) {
return ALLOWED_ASSOCIATIONS.has(association)
}

export function parseCommand(value) {
if (typeof value !== "string") throw new Error("The OpenCode command must be text.")
const body = value.replaceAll("\r\n", "\n")
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body)) {
throw new Error("The OpenCode command contains unsupported control characters.")
}
const match = body.match(
/^\/(?:oc|opencode) +(plan|build)(?: +--model +(sol|terra|luna))? +(.+)$/s,
)

if (!match) {
throw new Error(
"Expected `/oc <plan|build> [--model <sol|terra|luna>] <request>` with the command at the start of the comment.",
)
}

const mode = match[1]
const modelAlias = match[2]
const prompt = match[3].trim()
if (!prompt) throw new Error("The OpenCode request must not be empty.")
if (prompt.length > MAX_PROMPT_LENGTH) {
throw new Error(`The OpenCode request must not exceed ${MAX_PROMPT_LENGTH} characters.`)
}
if (GITHUB_ATTACHMENT_URL.test(prompt)) {
throw new Error("OpenCode commands cannot include GitHub attachment URLs.")
}
if (/^--model(?:\s|$)/i.test(prompt)) {
throw new Error("Model must be one of: sol, terra, luna.")
}

return {
mode,
model: modelAlias ? MODELS[modelAlias] : DEFAULT_MODELS[mode],
prompt,
}
}

function writeOutput(outputPath, name, value) {
const delimiter = `opencode_${randomUUID()}`
appendFileSync(outputPath, `${name}<<${delimiter}\n${value}\n${delimiter}\n`)
}

export function main(env = process.env) {
if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.")
if (!env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.")
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"))
const comment = event.comment
if (!comment) throw new Error("This workflow only accepts GitHub comment events.")
if (!isAllowedAssociation(comment.author_association)) {
throw new Error(`User association ${comment.author_association ?? "UNKNOWN"} is not authorized.`)
}

const command = parseCommand(comment.body)
writeOutput(env.GITHUB_OUTPUT, "mode", command.mode)
writeOutput(env.GITHUB_OUTPUT, "model", command.model)
writeOutput(env.GITHUB_OUTPUT, "prompt", command.prompt)
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
79 changes: 79 additions & 0 deletions .github/scripts/opencode-command.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "node:assert/strict"
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import test from "node:test"

import { main, MAX_PROMPT_LENGTH, isAllowedAssociation, parseCommand } from "./opencode-command.mjs"

test("uses separate defaults and preserves command aliases", () => {
assert.deepEqual(parseCommand("/oc plan inspect the role"), {
mode: "plan",
model: "openai/gpt-5.6-sol",
prompt: "inspect the role",
})
assert.deepEqual(parseCommand("/opencode build implement the role"), {
mode: "build",
model: "openai/gpt-5.6-terra",
prompt: "implement the role",
})
})

test("accepts only allowlisted model aliases", () => {
assert.equal(parseCommand("/oc build --model luna make the change").model, "openai/gpt-5.6-luna")
assert.throws(() => parseCommand("/oc build --model openai/gpt-5.6 make the change"))
})

test("requires an exact command at the beginning", () => {
assert.throws(() => parseCommand("please /oc plan inspect this"))
assert.throws(() => parseCommand("/oc build"))
assert.throws(() => parseCommand(" /oc plan inspect this"))
})

test("normalizes CRLF and preserves multiline prompts", () => {
assert.equal(parseCommand("/oc plan inspect this\r\nand include tests").prompt, "inspect this\nand include tests")
})

test("enforces the prompt length limit", () => {
assert.equal(parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH)}`).prompt.length, MAX_PROMPT_LENGTH)
assert.throws(() => parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH + 1)}`))
})

test("rejects unsupported control characters but permits tab and newline", () => {
assert.equal(parseCommand("/oc plan line\n\tmore").prompt, "line\n\tmore")
assert.throws(() => parseCommand("/oc plan nul\0value"))
assert.throws(() => parseCommand("/oc plan escape\x1bvalue"))
assert.throws(() => parseCommand("/oc plan delete\x7fvalue"))
})

test("rejects GitHub attachment URLs before OpenCode can download them", () => {
assert.throws(
() => parseCommand("/oc plan inspect https://github.com/user-attachments/assets/unbounded"),
/attachment URLs/,
)
})

test("allows only trusted repository associations", () => {
assert.equal(isAllowedAssociation("OWNER"), true)
assert.equal(isAllowedAssociation("MEMBER"), true)
assert.equal(isAllowedAssociation("COLLABORATOR"), true)
assert.equal(isAllowedAssociation("CONTRIBUTOR"), false)
})

test("main writes multiline outputs and validates its environment", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-command-"))
const eventPath = path.join(directory, "event.json")
const outputPath = path.join(directory, "output")
try {
await writeFile(eventPath, JSON.stringify({ comment: { author_association: "OWNER", body: "/oc plan first\nsecond" } }))
main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath })
const output = await readFile(outputPath, "utf8")
assert.match(output, /prompt<<opencode_[^\n]+\nfirst\nsecond\nopencode_/)
assert.throws(() => main({ GITHUB_OUTPUT: outputPath }), /GITHUB_EVENT_PATH/)
assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath }), /GITHUB_OUTPUT/)
await writeFile(eventPath, "{")
assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath }), SyntaxError)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
26 changes: 26 additions & 0 deletions .github/scripts/opencode-permissions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const BUILD_EDIT_PERMISSION = Object.freeze({
"*": "allow",
".git/**": "deny",
".github/**": "deny",
".opencode/**": "deny",
"opencode.json": "deny",
"AGENTS.md": "deny",
"vault_pass.sh": "deny",
".env": "deny",
".env.*": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
})

export function isGithubActions(env = process.env) {
return env.GITHUB_ACTIONS === "true"
}

export function enableGithubBuildEdits(config) {
const build = config?.agent?.["github-build"]
if (!build?.permission || build.permission.edit !== "deny") {
throw new Error("github-build must statically deny edits before guarded enablement")
}

build.permission.edit = { ...BUILD_EDIT_PERMISSION }
}
Loading
Loading