diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..5f58ec4 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,9 @@ -[] +[ + { + "level": "warning", + "role": "Mage", + "location": "app/preflight.js:23", + "suggestion": "在 `app/llm.js` 與 `app/preflight.js` 中,`opencodeAxiosOptions` 函數的邏輯存在重複。雖然 `timeout` 參數在兩處有所不同,但處理 `httpsAgent` 的核心邏輯是相同的。建議將 `httpsAgent` 的建立邏輯抽象為一個共用函數或在 `config.js` 中定義,以避免未來修改時造成不一致,並提高程式碼的可維護性。\n\n例如,可以將 `httpsAgent` 的邏輯移至 `config.js`:\n```javascript\n// app/config.js\nexport function getOpenCodeHttpsAgent() {\n return shouldSkipOpenCodeTLSVerify() ? new https.Agent({ rejectUnauthorized: false }) : undefined;\n}\n\n// app/llm.js\nimport { getOpenCodeHttpsAgent } from './config.js';\nfunction opencodeAxiosOptions(headers) {\n return {\n headers,\n httpsAgent: getOpenCodeHttpsAgent(),\n };\n}\n\n// app/preflight.js\nimport { getOpenCodeHttpsAgent } from './config.js';\nconst opencodeAxiosOptions = (headers) => ({\n headers,\n timeout: 30000,\n httpsAgent: getOpenCodeHttpsAgent(),\n});\n```", + "is_new": true + } +] diff --git a/README.md b/README.md index e956074..a6205d1 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,8 @@ jobs: OPENCODE_BASE_URL: http://192.168.3.124:4096 OPENCODE_PROVIDER: google OPENCODE_MODEL: gemini-2.5-flash + # 若 OpenCode server 使用自簽憑證,才需要提供: + # OPENCODE_SKIP_TLS_VERIFY: true # 若 OpenCode server 有設定 OPENCODE_SERVER_PASSWORD,才需要提供: # OPENCODE_SERVER_USERNAME: opencode # OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }} diff --git a/action.yaml b/action.yaml index 0d1e7cf..30b6eb4 100644 --- a/action.yaml +++ b/action.yaml @@ -95,6 +95,10 @@ inputs: OPENCODE_SERVER_PASSWORD: description: 'OpenCode server Basic Auth password' required: false + OPENCODE_SKIP_TLS_VERIFY: + description: '跳過 OpenCode server SSL/TLS 憑證驗證(自簽憑證時使用)' + required: false + default: 'false' runs: using: 'docker' @@ -129,3 +133,4 @@ runs: OPENCODE_PROVIDER: ${{ inputs.OPENCODE_PROVIDER }} OPENCODE_SERVER_USERNAME: ${{ inputs.OPENCODE_SERVER_USERNAME }} OPENCODE_SERVER_PASSWORD: ${{ inputs.OPENCODE_SERVER_PASSWORD }} + OPENCODE_SKIP_TLS_VERIFY: ${{ inputs.OPENCODE_SKIP_TLS_VERIFY }} diff --git a/app/config.js b/app/config.js index e2e0c6f..9ba433f 100644 --- a/app/config.js +++ b/app/config.js @@ -11,6 +11,10 @@ export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; +export function shouldSkipOpenCodeTLSVerify() { + return process.env.OPENCODE_SKIP_TLS_VERIFY === 'true'; +} + /** 將逗號分隔的 API key 字串拆成陣列 */ function splitKeys(value) { if (!value) return []; diff --git a/app/llm.js b/app/llm.js index 12a0dd7..d4e6214 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,5 +1,6 @@ import axios from 'axios'; -import { getLLMConfig } from './config.js'; +import https from 'https'; +import { getLLMConfig, shouldSkipOpenCodeTLSVerify } from './config.js'; import { line, error } from './log.js'; function isOpenAIGpt55(provider, model) { @@ -46,6 +47,13 @@ function applyOpenCodeAuth(headers) { headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; } +function opencodeAxiosOptions(headers) { + return { + headers, + httpsAgent: shouldSkipOpenCodeTLSVerify() ? new https.Agent({ rejectUnauthorized: false }) : undefined, + }; +} + function extractOpenCodeContent(data) { const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || []; return parts @@ -60,7 +68,7 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) const session = await axios.post( `${base}/session`, { title: 'AI Code Review', model: { providerID, id: modelID } }, - { headers } + opencodeAxiosOptions(headers) ); const sessionID = session.data.id || session.data.data?.id; if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id'); @@ -72,7 +80,7 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) system: systemPrompt, parts: [{ type: 'text', text: userContent }], }, - { headers } + opencodeAxiosOptions(headers) ); return extractOpenCodeContent(resp.data); } diff --git a/app/llm.test.js b/app/llm.test.js index 2024946..541fdbb 100644 --- a/app/llm.test.js +++ b/app/llm.test.js @@ -12,6 +12,7 @@ const ENV_KEYS = [ 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', + 'OPENCODE_SKIP_TLS_VERIFY', ]; let saved = {}; @@ -163,6 +164,21 @@ describe('chat - key rotation', async () => { assert.equal(headers[0]['Authorization'], `Basic ${Buffer.from('opencode:secret').toString('base64')}`); }); + it('passes an insecure https agent to OpenCode when TLS verification is disabled', async () => { + process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; + process.env.OPENCODE_SKIP_TLS_VERIFY = 'true'; + const agents = []; + mock.method(axios, 'post', async (url, _payload, opts) => { + agents.push(opts.httpsAgent); + if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; + return { data: { parts: [{ type: 'text', text: 'ok' }] } }; + }); + await chat('sys', 'user'); + assert.equal(agents.length, 2); + assert.equal(agents[0].options.rejectUnauthorized, false); + assert.equal(agents[1].options.rejectUnauthorized, false); + }); + it('uses Responses API for openai GPT-5.5', async () => { process.env.OPENAI_API_KEY = 'sk-test'; process.env.OPENAI_MODEL = 'GPT-5.5'; diff --git a/app/preflight.js b/app/preflight.js index bcaa394..6690bbe 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -8,6 +8,7 @@ import { GITEA_SKIP_TLS_VERIFY, PR_NUMBER, getLLMConfig, + shouldSkipOpenCodeTLSVerify, } from './config.js'; import { verifyRemoteAccess } from './git.js'; import { step, line, ok, error } from './log.js'; @@ -26,6 +27,11 @@ const applyOpenCodeAuth = (headers) => { const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; }; +const opencodeAxiosOptions = (headers) => ({ + headers, + timeout: 30000, + httpsAgent: shouldSkipOpenCodeTLSVerify() ? new https.Agent({ rejectUnauthorized: false }) : undefined, +}); function giteaErr(e) { const status = e.response?.status; @@ -89,8 +95,8 @@ export async function verifyLLM() { const { providerID, modelID } = opencodeModelConfig(model); applyOpenCodeAuth(headers); try { - await axios.get(`${base}/global/health`, { headers, timeout: 30000 }); - const providers = await axios.get(`${base}/config/providers`, { headers, timeout: 30000 }); + await axios.get(`${base}/global/health`, opencodeAxiosOptions(headers)); + const providers = await axios.get(`${base}/config/providers`, opencodeAxiosOptions(headers)); const configuredProvider = providers.data.providers?.find(p => p.id === providerID); if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` }; if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` }; diff --git a/app/preflight.test.js b/app/preflight.test.js index 7a49678..91eced4 100644 --- a/app/preflight.test.js +++ b/app/preflight.test.js @@ -11,6 +11,7 @@ const LLM_ENV_KEYS = [ 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', + 'OPENCODE_SKIP_TLS_VERIFY', ]; function clearLLMEnv() { @@ -182,6 +183,23 @@ describe('verifyLLM', () => { assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']); }); + it('passes an insecure https agent for opencode when TLS verification is disabled', async () => { + clearLLMEnv(); + process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; + process.env.OPENCODE_SKIP_TLS_VERIFY = 'true'; + const agents = []; + mock.method(axios, 'get', async (url, opts) => { + agents.push(opts.httpsAgent); + if (url.endsWith('/global/health')) return { data: { healthy: true } }; + return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; + }); + const result = await verifyLLM(); + assert.equal(result.ok, true); + assert.equal(agents.length, 2); + assert.equal(agents[0].options.rejectUnauthorized, false); + assert.equal(agents[1].options.rejectUnauthorized, false); + }); + it('checks openai GPT-5.5 with Responses API', async () => { clearLLMEnv(); process.env.OPENAI_API_KEY = 'sk-test';