Support OpenCodeReview native configuration directories

Use the selected native home consistently for OCR detection, readiness, execution and session isolation, and explain the home-relative behavior in settings.

Checks: desktop native configuration and security tests, frontend tests and builds, macOS pack, real OCR CLI with an isolated native home.
This commit is contained in:
Ryder Sun committed 2026-11-07 18:45:23 +08:00
1 parent 4a9fa31812
commit 90e43a3011
13 files changed
+98 -15

No files matched your search

+2
View File
@@ -111,6 +111,8 @@ For Zcode, choose an existing `.zcode/cli` directory containing `config.json`. Z
For Qwen Code, choose an existing `.qwen` directory containing settings or credentials. Qwen uses its parent user home for native sessions, Skills and other home-relative files; project and system settings retain their native precedence.
For OpenCodeReview, choose an existing `.opencodereview` directory containing `config.json`. OCR runs with that directory's parent user home, including its native session and other home-relative paths.
Hermes uses its native `config.yaml` and Provider settings. When its model, fallback model, or provider entries declare credential variables with `key_env` or `api_key_env`, Meldwork reads those variables from the launch environment or login shell for Hermes; unrelated variables and unsafe process controls are excluded. Hermes may prepare its own dependencies when started with a new native data directory.
## Context and local boundaries
@@ -122,6 +122,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
...GOOGLE_CLOUD_ENV_KEYS,
],
opencodereview: [
'HOME', 'USERPROFILE',
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
'OCR_LLM_PROTOCOL', 'OCR_LLM_AUTH_HEADER', 'OCR_LLM_TIMEOUT',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
+7 -7
View File
@@ -705,8 +705,8 @@ function nativeCredentialState(kind, options = {}) {
}
const home = options.home || os.homedir()
const env = options.env || process.env
const configuredQwenHome = (options.platform || process.platform) === 'win32' ? env.USERPROFILE : env.HOME
const qwenHome = typeof configuredQwenHome === 'string' && configuredQwenHome.trim() ? configuredQwenHome.trim() : home
const configuredUserHome = (options.platform || process.platform) === 'win32' ? env.USERPROFILE : env.HOME
const effectiveUserHome = typeof configuredUserHome === 'string' && configuredUserHome.trim() ? configuredUserHome.trim() : home
const codexHome = env.CODEX_HOME || path.join(home, '.codex')
const claudeHome = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
const geminiHome = env.GEMINI_CLI_HOME
@@ -738,7 +738,7 @@ function nativeCredentialState(kind, options = {}) {
const [key] = nativeSecretEnvKeys(options.nativeCredentialKeys)
const inherited = key && Object.hasOwn(env, key)
const envFile = !inherited && key
? [path.join(qwenHome, '.qwen', '.env'), path.join(qwenHome, '.env')].find(file => fs.existsSync(file)) : ''
? [path.join(effectiveUserHome, '.qwen', '.env'), path.join(effectiveUserHome, '.env')].find(file => fs.existsSync(file)) : ''
const configured = inherited ? typeof env[key] === 'string' && env[key].trim()
: envFile && dotenvContainsCredential(envFile, key)
return configured
@@ -787,10 +787,10 @@ function nativeCredentialState(kind, options = {}) {
path.join(claudeHome, 'settings.json'),
path.join(claudeHome, 'settings.local.json'),
],
qwen: [path.join(qwenHome, '.qwen', 'oauth_creds.json'), path.join(qwenHome, '.qwen', 'settings.json')],
qwen: [path.join(effectiveUserHome, '.qwen', 'oauth_creds.json'), path.join(effectiveUserHome, '.qwen', 'settings.json')],
gemini: geminiHome ? [path.join(geminiHome, 'oauth_creds.json'), path.join(geminiHome, 'settings.json')] : [],
opencode: [openCodeAuth],
opencodereview: [path.join(home, '.opencodereview', 'config.json')],
opencodereview: [path.join(effectiveUserHome, '.opencodereview', 'config.json')],
}[kind] || []
if (jsonFiles.some(filename => kind === 'pi' ? !piCloudflare && piAuthContainsCredential(filename, piProvider)
: hermesSelection ? path.basename(filename) === 'auth.json'
@@ -820,13 +820,13 @@ function nativeCredentialState(kind, options = {}) {
],
mimo: [path.join(home, '.mimo', '.env'), path.join(home, '.mimocode', '.env')],
claude: [path.join(claudeHome, '.env')],
qwen: [path.join(qwenHome, '.qwen', '.env')],
qwen: [path.join(effectiveUserHome, '.qwen', '.env')],
gemini: geminiHome ? [path.join(geminiHome, '.env')] : [],
opencode: [
path.join(home, '.opencode', '.env'),
path.join(home, '.config', 'opencode', '.env'),
],
opencodereview: [path.join(home, '.opencodereview', '.env')],
opencodereview: [path.join(effectiveUserHome, '.opencodereview', '.env')],
}[kind] || []
if (textFiles.some(filename => hermesSelection && filename === path.join(hermesHome, 'config.yaml')
? hermesConfigContainsCredential(hermesSelection)
@@ -164,12 +164,13 @@ const NATIVE_CONFIG_DIRECTORIES = Object.freeze({
workbuddy: { key: 'CODEBUDDY_CONFIG_DIR', files: ['models.json', 'settings.json', '.credentials.json'] },
zcode: { key: 'HOME', files: ['config.json'] },
qwen: { key: 'HOME', files: ['settings.json', 'oauth_creds.json', '.env'], identityFiles: ['trustedFolders.json'] },
opencodereview: { key: 'HOME', files: ['config.json'], identityFiles: ['.env'] },
})
function nativeIdentityDirectory(kind, env, home, platform = process.platform) {
if (kind === 'qwen') {
if (kind === 'qwen' || kind === 'opencodereview') {
const paths = platform === 'win32' ? path.win32 : path.posix
const base = (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || home
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, '.qwen') : ''
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, kind === 'qwen' ? '.qwen' : '.opencodereview') : ''
}
if (kind === 'zcode') {
const paths = platform === 'win32' ? path.win32 : path.posix
@@ -207,10 +208,10 @@ function nativeConfigEnvironment(kind, preferences, env = {}, platform = process
if (typeof directory !== 'string' || directory.length > 4096
|| directory.includes('\u0000') || !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'gemini' && path.basename(path.resolve(directory)) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'qwen') {
if (kind === 'qwen' || kind === 'opencodereview') {
const resolved = path.resolve(directory)
const name = path.basename(resolved)
if ((platform === 'win32' ? name.toLowerCase() : name) !== '.qwen') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if ((platform === 'win32' ? name.toLowerCase() : name) !== (kind === 'qwen' ? '.qwen' : '.opencodereview')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const home = path.dirname(resolved)
return platform === 'win32' ? { ...env, HOME: home, USERPROFILE: home } : { ...env, HOME: home }
}
@@ -233,6 +234,7 @@ function validateNativeConfigDirectory(kind, directory) {
if (kind === 'gemini' && path.basename(path.resolve(directory)) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const caseName = value => process.platform === 'win32' ? value.toLowerCase() : value
if (kind === 'qwen' && caseName(path.basename(path.resolve(directory))) !== '.qwen') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'opencodereview' && caseName(path.basename(path.resolve(directory))) !== '.opencodereview') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'zcode' && (caseName(path.basename(path.resolve(directory))) !== 'cli'
|| caseName(path.basename(path.dirname(path.resolve(directory)))) !== '.zcode')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
let resolved
@@ -245,7 +247,7 @@ function validateNativeConfigDirectory(kind, directory) {
try { return fs.statSync(path.join(resolved, name)).isDirectory() } catch { return false }
}))) throw new Error('invalid directory')
} catch { throw new Error('LOCAL_NATIVE_CONFIG_INVALID') }
return ['gemini', 'zcode', 'qwen'].includes(kind) ? path.resolve(directory) : resolved
return ['gemini', 'zcode', 'qwen', 'opencodereview'].includes(kind) ? path.resolve(directory) : resolved
}
module.exports = { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS,
+1 -1
View File
@@ -316,7 +316,7 @@ async function nativeShellEnvironment(options = {}) {
for (const key of resolved.unsetKeys || []) delete env[key]
opencodeCredentialKeys = opencodeCredentialKeys.filter(key => typeof env[key] === 'string' && env[key].length > 0)
for (const [kind, { key }] of Object.entries(NATIVE_CONFIG_DIRECTORIES)) {
if (kind === 'zcode') continue
if (kind === 'zcode' || kind === 'opencodereview') continue
if (kind === 'qwen') {
const directory = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || ''
if (Object.hasOwn(nativeSkillDirectories, key) && nativeSkillDirectories[key] !== directory) skillCatalog?.invalidate(kind)
@@ -42,6 +42,16 @@ test('OpenCodeReview receives its native Anthropic configuration without exposin
assert.equal(redactChildSecrets('synthetic-token synthetic-key', child), '[redacted] [redacted]')
})
test('OpenCodeReview readiness reads the selected native home', t => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-ocr-readiness-'))
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
const selected = path.join(home, 'selected')
fs.mkdirSync(path.join(selected, '.opencodereview'), { recursive: true })
fs.writeFileSync(path.join(selected, '.opencodereview', 'config.json'), JSON.stringify({ api_key: 'synthetic-key' }))
assert.equal(nativeCredentialState('opencodereview', { home, env: { HOME: selected } }).state, 'ready')
assert.equal(nativeCredentialState('opencodereview', { home, env: {} }).state, 'unknown')
})
test('Hermes receives native Bedrock and Vertex environment for discovery and execution', async () => {
const env = { AWS_PROFILE: 'research', AWS_REGION: 'us-east-1', AWS_ACCESS_KEY_ID: 'native-access',
AWS_SECRET_ACCESS_KEY: 'native-secret', GOOGLE_APPLICATION_CREDENTIALS: '/native/google.json',
@@ -5,6 +5,36 @@ const os = require('node:os')
const path = require('node:path')
const { nativeAuthIdentityFiles, nativeConfigEnvironment, nativeIdentityDirectory, nativeProjectConfigPaths, validateNativeConfigDirectory } = require('../../src/agents/native-config-directory.cjs')
test('OpenCodeReview selects its native configuration directory and parent home', t => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-ocr-config-home-'))
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
const directory = path.join(home, '.opencodereview')
fs.mkdirSync(directory)
assert.throws(() => validateNativeConfigDirectory('opencodereview', directory), { message: 'LOCAL_NATIVE_CONFIG_INVALID' })
fs.writeFileSync(path.join(directory, 'config.json'), '{}')
assert.equal(validateNativeConfigDirectory('opencodereview', directory), directory)
const preferences = { opencodereview: { nativeConfigDirectory: directory } }
assert.equal(nativeConfigEnvironment('opencodereview', preferences, { HOME: '/old' }).HOME, home)
const { childEnvironment } = require('../../src/agents/cli/cli-process-support.cjs')
const selected = nativeConfigEnvironment('opencodereview', preferences, { HOME: '/old' })
assert.equal(childEnvironment({ kind: 'opencodereview' }, home, { env: selected }, process.platform).HOME, home)
assert.deepEqual(nativeConfigEnvironment('opencodereview', preferences, {}, 'win32'), { HOME: home, USERPROFILE: home })
const windows = nativeConfigEnvironment('opencodereview', preferences, {}, 'win32')
assert.equal(childEnvironment({ kind: 'opencodereview' }, home, { env: windows }, 'win32').USERPROFILE, home)
assert.equal(nativeIdentityDirectory('opencodereview', { HOME: home }, '/other', 'linux'), directory)
assert.throws(() => nativeConfigEnvironment('opencodereview', { opencodereview: { nativeConfigDirectory: home } }),
{ message: 'LOCAL_NATIVE_CONFIG_INVALID' })
if (process.platform !== 'win32') {
assert.throws(() => nativeConfigEnvironment('opencodereview', { opencodereview: { nativeConfigDirectory: path.join(home, '.OPENCODEREVIEW') } }),
{ message: 'LOCAL_NATIVE_CONFIG_INVALID' })
const alias = path.join(home, 'alias', '.opencodereview')
fs.mkdirSync(path.dirname(alias))
fs.symlinkSync(directory, alias, 'dir')
assert.equal(validateNativeConfigDirectory('opencodereview', alias), alias)
assert.equal(nativeConfigEnvironment('opencodereview', { opencodereview: { nativeConfigDirectory: alias } }).HOME, path.dirname(alias))
}
})
test('OpenCodeReview verification changes when native credentials or configuration change', t => {
const { NATIVE_AUTH_IDENTITY_FILES } = require('../../src/agents/native-config-directory.cjs')
const { agentVerificationIdentity } = require('../../src/workspace/agent-verification-identity.cjs')
@@ -680,6 +680,34 @@ test('Qwen selected native configuration controls credentials, execution and Ski
assert.equal(harness.skillCatalogInstances[0].input.configDirectoryForKind('qwen'), path.resolve(home))
})
test('OpenCodeReview selected native directory reaches detection and execution', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-ocr-config-main-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const home = path.join(directory, 'selected-home')
const selected = path.join(home, '.opencodereview')
fs.mkdirSync(selected, { recursive: true })
fs.writeFileSync(path.join(selected, 'config.json'), '{}')
const readiness = require('../../src/agents/local-agent-readiness.cjs')
let detected
const { harness } = loadMain(directory, {
dialogResult: { canceled: false, filePaths: [selected] },
nativeEnvironment: readiness.nativeCredentialEnvironment,
detectAgents: input => { detected = input.nativeEnvironmentForKind('opencodereview'); return [] },
})
await harness.ready()
const workspace = harness.workspaceInstances[0]
workspace.state.agentPreferences = {}
workspace.state.agentRuntime = {}
workspace.activeRuns = new Map()
workspace.save = () => {}
const choice = await harness.ipcHandlers.get('local-agent-native-config:choose')(harness.event(), 'opencodereview')
assert.equal(choice.directory, selected)
await harness.installerInstances[0].input.detectAgents()
assert.equal(detected.HOME, home)
await workspace.input.runAgent({ kind: 'opencodereview', executable: '/tmp/ocr' }, 'hello', directory, {})
assert.equal(harness.runAgentCalls.at(-1)[3].env.HOME, home)
})
test('Qwen verification follows the selected native home environment file', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-qwen-selected-identity-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -49,6 +49,14 @@ it('explains the Qwen home directory used by the selected configuration', async
wrapper.unmount()
})
it('explains the OpenCodeReview native directory', async () => {
window.meldworkDesktop = { localAgentNativeConfig: { get: vi.fn().mockResolvedValue({ directory: '' }) } }
const wrapper = mount(AgentNativeConfig, { props: { kind: 'opencodereview' } })
await flushPromises()
expect(wrapper.text()).toContain(t('nativeConfig.expected.opencodereview'))
wrapper.unmount()
})
it('does not claim automatic discovery when the saved directory cannot be read', async () => {
window.meldworkDesktop = { localAgentNativeConfig: {
get: vi.fn().mockRejectedValue(new Error('LOCAL_WORKSPACE_UNAVAILABLE')),
@@ -2,7 +2,7 @@
<section class="agent-detail-section">
<h3>{{ t('nativeConfig.title') }}</h3>
<p v-if="directory !== null">{{ directory || t('nativeConfig.automatic') }}</p>
<p v-if="['zcode', 'qwen'].includes(kind)">{{ t(`nativeConfig.expected.${kind}`) }}</p>
<p v-if="['zcode', 'qwen', 'opencodereview'].includes(kind)">{{ t(`nativeConfig.expected.${kind}`) }}</p>
<p v-if="directoryMissing" class="form-error" role="alert">{{ t('nativeConfig.directoryMissing') }} {{ t(`nativeConfig.expected.${kind}`) }}</p>
<p v-if="kind === 'hermes' && directory !== null">{{ t('nativeConfig.configuredProvider') }}: {{ provider || t('nativeConfig.providerUnknown') }}</p>
<p v-if="['codex', 'zcode'].includes(kind) && directory !== null">{{ t('nativeConfig.userConfiguredProvider') }}: {{ provider || t('nativeConfig.providerUnknown') }}</p>
@@ -353,7 +353,7 @@ import AgentNativeConfig from './AgentNativeConfig.vue'
import { desktopApi } from '../desktop.js'
const nativeConfigAvailable = Boolean(desktopApi()?.localAgentNativeConfig)
const nativeConfigKinds = ['hermes', 'codex', 'claude', 'pi', 'kimi', 'mimo', 'deepseek', 'workbuddy', 'opencode', 'gemini', 'zcode', 'qwen']
const nativeConfigKinds = ['hermes', 'codex', 'claude', 'pi', 'kimi', 'mimo', 'deepseek', 'workbuddy', 'opencode', 'gemini', 'zcode', 'qwen', 'opencodereview']
const props = defineProps({
+1
View File
@@ -22,6 +22,7 @@ export default {
'nativeConfig.expected.gemini': 'Choose a .gemini directory containing settings or credentials.',
'nativeConfig.expected.zcode': 'Choose a .zcode/cli directory containing config.json. Zcode uses its parent user home.',
'nativeConfig.expected.qwen': 'Choose a .qwen directory containing settings or credentials. Qwen uses its parent user home.',
'nativeConfig.expected.opencodereview': 'Choose a .opencodereview directory containing config.json. OpenCodeReview uses its parent user home.',
'skillTrust.title': 'Skill trust',
'skillTrust.hint': 'Review trust granted to an Agent and exact Skill content. Changed content requires approval again.',
'skillTrust.refresh': 'Refresh',
+1
View File
@@ -22,6 +22,7 @@ export default {
'nativeConfig.expected.gemini': '请选择包含设置或认证文件的 .gemini 目录。',
'nativeConfig.expected.zcode': '请选择包含 config.json 的 .zcode/cli 目录;Zcode 会使用其所属的用户主目录。',
'nativeConfig.expected.qwen': '请选择包含设置或认证文件的 .qwen 目录;Qwen 会使用其所属的用户主目录。',
'nativeConfig.expected.opencodereview': '请选择包含 config.json 的 .opencodereview 目录;OpenCodeReview 会使用其所属的用户主目录。',
'skillTrust.title': 'Skill 信任',
'skillTrust.hint': '查看按 Agent 和内容绑定的信任授权。内容变化后需要重新授权。',
'skillTrust.refresh': '刷新',