Compare commits
11 Commits
v0.2.6
...
v0.2.9-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| a02d7f374c | |||
| 6036ce45c4 | |||
| 648334d153 | |||
| 9d759464c2 | |||
| 12d7403a0e | |||
| babe599977 | |||
| c662a2e362 | |||
| 7a79cd45be | |||
| 46a4ff91ce | |||
| 82d2a943ac | |||
| f7e4f09d4e |
@@ -1 +1,16 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"level": "warning",
|
||||
"role": "Mage",
|
||||
"location": "app/config.test.js",
|
||||
"suggestion": "`shouldSkipOpenCodeTLSVerify` 函式的新增測試案例未能涵蓋所有可能的輸入情境。在 `process.env.OPENCODE_SKIP_TLS_VERIFY !== 'false'` 的新邏輯下,應增加測試案例來驗證當環境變數設定為空字串 `''`、字串 `'0'` 或其他任意非 `'false'` 字串時,函式是否如預期般返回 `true`(跳過 TLS 驗證)。這有助於確保此關鍵安全邏輯的行為符合預期,並揭示潛在的誤配置風險。",
|
||||
"is_new": true
|
||||
},
|
||||
{
|
||||
"level": "warning",
|
||||
"role": "Maya",
|
||||
"location": "app/preflight.test.js",
|
||||
"suggestion": "在 `preflight.test.js` 中,關於 `httpsAgent` 的測試案例也已涵蓋了預設行為(跳過 TLS)和明確設定為 `false`(不跳過 TLS)的情況。請新增一個測試,驗證當環境變數 `process.env.OPENCODE_SKIP_TLS_VERIFY` 明確設定為 `'true'` 時,`verifyLLM` 函式是否會傳遞一個不安全的 `httpsAgent` 給 OpenCode 服務進行預檢。",
|
||||
"is_new": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -224,6 +224,8 @@ jobs:
|
||||
OPENCODE_BASE_URL: http://192.168.3.124:4096
|
||||
OPENCODE_PROVIDER: google
|
||||
OPENCODE_MODEL: gemini-2.5-flash
|
||||
# 預設會跳過 OpenCode TLS 驗證;若要強制驗證憑證才需要設定:
|
||||
# OPENCODE_SKIP_TLS_VERIFY: false
|
||||
# 若 OpenCode server 有設定 OPENCODE_SERVER_PASSWORD,才需要提供:
|
||||
# OPENCODE_SERVER_USERNAME: opencode
|
||||
# OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
|
||||
|
||||
@@ -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: 'true'
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import https from 'https';
|
||||
|
||||
export const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||
export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || '';
|
||||
export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com';
|
||||
@@ -11,6 +13,14 @@ 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 !== 'false';
|
||||
}
|
||||
|
||||
export function getOpenCodeHttpsAgent() {
|
||||
return shouldSkipOpenCodeTLSVerify() ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
}
|
||||
|
||||
/** 將逗號分隔的 API key 字串拆成陣列 */
|
||||
function splitKeys(value) {
|
||||
if (!value) return [];
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLLMConfig } from './config.js';
|
||||
import { getLLMConfig, shouldSkipOpenCodeTLSVerify } from './config.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
@@ -10,6 +10,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 = {};
|
||||
@@ -104,6 +105,15 @@ describe('getLLMConfig', () => {
|
||||
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('skips OpenCode TLS verification by default', () => {
|
||||
assert.equal(shouldSkipOpenCodeTLSVerify(), true);
|
||||
});
|
||||
|
||||
it('allows explicitly enabling OpenCode TLS verification', () => {
|
||||
process.env.OPENCODE_SKIP_TLS_VERIFY = 'false';
|
||||
assert.equal(shouldSkipOpenCodeTLSVerify(), false);
|
||||
});
|
||||
|
||||
it('openai takes priority over gemini when both set', () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-test';
|
||||
process.env.GEMINI_API_KEY = 'gemini-key';
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { getLLMConfig } from './config.js';
|
||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
||||
import { line, error } from './log.js';
|
||||
|
||||
function isOpenAIGpt55(provider, model) {
|
||||
@@ -46,6 +46,13 @@ function applyOpenCodeAuth(headers) {
|
||||
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
function opencodeAxiosOptions(headers) {
|
||||
return {
|
||||
headers,
|
||||
httpsAgent: getOpenCodeHttpsAgent(),
|
||||
};
|
||||
}
|
||||
|
||||
function extractOpenCodeContent(data) {
|
||||
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
||||
return parts
|
||||
@@ -60,7 +67,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 +79,7 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
|
||||
system: systemPrompt,
|
||||
parts: [{ type: 'text', text: userContent }],
|
||||
},
|
||||
{ headers }
|
||||
opencodeAxiosOptions(headers)
|
||||
);
|
||||
return extractOpenCodeContent(resp.data);
|
||||
}
|
||||
|
||||
@@ -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,33 @@ 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 by default', async () => {
|
||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||
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('does not pass an insecure https agent to OpenCode when TLS verification is enabled', async () => {
|
||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||
process.env.OPENCODE_SKIP_TLS_VERIFY = 'false';
|
||||
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.deepEqual(agents, [undefined, undefined]);
|
||||
});
|
||||
|
||||
it('uses Responses API for openai GPT-5.5', async () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-test';
|
||||
process.env.OPENAI_MODEL = 'GPT-5.5';
|
||||
|
||||
+8
-2
@@ -7,6 +7,7 @@ import {
|
||||
GITEA_REPOSITORY,
|
||||
GITEA_SKIP_TLS_VERIFY,
|
||||
PR_NUMBER,
|
||||
getOpenCodeHttpsAgent,
|
||||
getLLMConfig,
|
||||
} from './config.js';
|
||||
import { verifyRemoteAccess } from './git.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: getOpenCodeHttpsAgent(),
|
||||
});
|
||||
|
||||
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}` };
|
||||
|
||||
@@ -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,37 @@ 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 by default', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||
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('does not pass an insecure https agent for opencode when TLS verification is enabled', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||
process.env.OPENCODE_SKIP_TLS_VERIFY = 'false';
|
||||
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.deepEqual(agents, [undefined, undefined]);
|
||||
});
|
||||
|
||||
it('checks openai GPT-5.5 with Responses API', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.OPENAI_API_KEY = 'sk-test';
|
||||
|
||||
Reference in New Issue
Block a user