Support OpenClaw native state directories

Use selected or shell-configured state roots for native discovery while retaining guarded path and isolated execution checks. Verified readiness, security and frontend tests, frontend builds and desktop packaging.
This commit is contained in:
Ryder Sun committed 2026-11-07 21:09:09 +08:00
1 parent 1c8eb35ad5
commit 00a0353f6d
12 files changed
+68 -6

No files matched your search

+2
View File
@@ -105,6 +105,8 @@ An explicit ACP provider or model in the native profile or DSH home patch takes
WorkBuddy also supports selecting its existing native configuration directory in Settings → Agents. Its model list, execution environment, and local and plugin Skills use the selected directory.
OpenClaw also supports selecting its native state directory in Settings → Agents. Meldwork reads the selected Agent's model configuration from that directory and runs it in its guarded local runtime.
When switching an Agent's native configuration directory, Meldwork keeps separate local conversation sessions for each selected directory and restores them when switching back. Configuration changes are blocked while a run is active or awaiting recovery.
For Zcode, choose an existing `.zcode/cli` directory containing `config.json`. Zcode runs with that directory's parent user home, which also affects its home-relative session and other file paths; project configuration may still override user configuration.
+2
View File
@@ -105,6 +105,8 @@ DeepSeek Harness(`dsh`)通过 ACP 支持私聊与群聊,各对话使用独
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
@@ -42,7 +42,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', ...AWS_SDK_ENV_KEYS,
'VERTEX_CREDENTIALS_PATH', 'VERTEX_PROJECT_ID', 'VERTEX_REGION', ...GOOGLE_CLOUD_ENV_KEYS,
],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'OPENCLAW_STATE_DIR'],
workbuddy: [
'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
'CODEBUDDY_CONFIG_DIR',
+3 -1
View File
@@ -1083,7 +1083,9 @@ function parseOpenClawStatus(output, options = {}) {
return { credentialState: { state: 'unknown', source: 'native-runtime-unavailable' } }
}
const resolvedHome = fs.realpathSync(path.resolve(options.home || os.homedir()))
const nativeRoot = path.join(resolvedHome, '.openclaw')
const configuredRoot = options.env?.OPENCLAW_STATE_DIR
if (configuredRoot && !path.isAbsolute(configuredRoot)) return null
const nativeRoot = configuredRoot ? path.resolve(configuredRoot) : path.join(resolvedHome, '.openclaw')
const nativeRootIdentity = openClawDirectoryIdentity(nativeRoot)
const agentDir = fs.realpathSync(requestedAgentDir)
if (!fs.statSync(agentDir).isDirectory() || !pathInside(nativeRoot, agentDir)) {
@@ -154,6 +154,7 @@ function nativeAuthIdentityFiles(kind, env, home, platform = process.platform) {
const NATIVE_CONFIG_DIRECTORIES = Object.freeze({
hermes: { key: 'HERMES_HOME', files: ['config.yaml', 'auth.json', '.env'], identityFiles: ['config.json'] },
openclaw: { key: 'OPENCLAW_STATE_DIR', files: ['openclaw.json'], directories: ['agents'] },
codex: { key: 'CODEX_HOME', files: ['config.toml', 'auth.json'], identityFiles: ['config.json', '.env'] },
claude: { key: 'CLAUDE_CONFIG_DIR', files: ['settings.json', '.credentials.json'], identityFiles: ['settings.local.json', '.env'] },
pi: { key: 'PI_CODING_AGENT_DIR', files: ['settings.json', 'auth.json', 'models.json'] },
@@ -193,7 +194,7 @@ function nativeIdentityDirectory(kind, env, home, platform = process.platform) {
hermes: platform === 'win32'
? path.join(env?.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`)
: path.join(home, `.hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`),
codex: path.join(home, '.codex'), claude: path.join(home, '.claude'),
codex: path.join(home, '.codex'), claude: path.join(home, '.claude'), openclaw: path.join(home, '.openclaw'),
pi: path.join(home, '.pi', 'agent'), kimi: path.join(home, '.kimi-code'),
mimo: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'mimocode'),
opencode: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'),
+2 -2
View File
@@ -406,7 +406,7 @@ async function resolveProjectEnvironmentIdentities(workdir, kinds) {
if (!agent?.executable) continue
try {
const runtime = await resolveNativeOpenClawRuntime({ executable: agent.executable,
env: captured.env, home: captured.home })
env: nativeConfigEnvironment(kind, workspace?.state.agentPreferences, captured.env), home: captured.home })
result[kind] = nativeEnvironmentIdentity({ runtime: JSON.stringify(runtime),
...networkEnvironment(captured.env) })
} catch { /* Fail closed for this Agent without blocking other kinds. */ }
@@ -546,7 +546,7 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw'
? await resolveNativeOpenClawRuntime({
executable: agent.executable,
env: shellEnvironment.env,
env: selectedNativeEnv,
home: shellEnvironment.home,
})
: null
@@ -1538,6 +1538,26 @@ test('OpenClaw native runtime rejects Env SecretRefs without reading unrelated p
assert.equal(calls[0].env.GITHUB_TOKEN, undefined)
})
test('OpenClaw native runtime accepts a selected state root but rejects agents outside it', async t => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-selected-root-'))
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
const selected = fs.realpathSync(home)
const agentDir = path.join(selected, 'agents', 'main', 'agent')
fs.mkdirSync(agentDir, { recursive: true })
fs.writeFileSync(path.join(agentDir, 'models.json'), openClawModelsCatalog('synthetic-key'))
const status = directory => JSON.stringify({ resolvedDefault: 'provider/model', agentDir: directory,
auth: { missingProvidersInUse: [] } })
const options = { home, env: { OPENCLAW_STATE_DIR: selected }, executable: '/tmp/openclaw',
execFileFn: async (_command, _args, child) => {
assert.equal(child.env.OPENCLAW_STATE_DIR, selected)
return { stdout: status(agentDir) }
} }
assert.equal((await resolveNativeOpenClawRuntime(options)).provider.apiKey, 'synthetic-key')
await assert.rejects(resolveNativeOpenClawRuntime({ ...options,
execFileFn: async () => ({ stdout: status(path.dirname(selected)) }) }),
{ message: 'OPENCLAW_NATIVE_RUNTIME_UNAVAILABLE' })
})
test('OpenClaw native runtime rejects every structured and string SecretRef marker', async (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-secretrefs-'))
const agentDir = path.join(home, '.openclaw', 'agents', 'main', 'agent')
@@ -5,6 +5,18 @@ const os = require('node:os')
const path = require('node:path')
const { nativeAuthIdentityFiles, nativeConfigEnvironment, nativeIdentityDirectory, nativeProjectConfigPaths, validateNativeConfigDirectory } = require('../../src/agents/native-config-directory.cjs')
test('OpenClaw selects its native state directory without changing other Agents', t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-config-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
assert.throws(() => validateNativeConfigDirectory('openclaw', root), { message: 'LOCAL_NATIVE_CONFIG_INVALID' })
fs.mkdirSync(path.join(root, 'agents'))
const selected = validateNativeConfigDirectory('openclaw', root)
const preferences = { openclaw: { nativeConfigDirectory: selected } }
assert.equal(nativeConfigEnvironment('openclaw', preferences, {}).OPENCLAW_STATE_DIR, selected)
assert.equal(nativeIdentityDirectory('openclaw', { OPENCLAW_STATE_DIR: selected }, root), selected)
assert.equal(nativeConfigEnvironment('hermes', preferences, {}).OPENCLAW_STATE_DIR, undefined)
})
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 }))
@@ -991,6 +991,27 @@ test('OpenClaw project identity tracks its guarded native runtime without exposi
assert.equal(failed.codex, second.codex)
})
test('OpenClaw selected state directory reaches runtime discovery and isolated execution', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-selected-main-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const selected = path.join(directory, 'native-state')
fs.mkdirSync(path.join(selected, 'agents'), { recursive: true })
const calls = []
const { harness } = loadMain(directory, {
nativeOpenClawRuntime: input => {
calls.push(input.env.OPENCLAW_STATE_DIR)
return { model: 'native/model', provider: { id: 'native', baseUrl: 'https://example.test', apiKey: 'synthetic' } }
},
})
await harness.ready()
const workspace = harness.workspaceInstances[0]
workspace.state.agentPreferences = { openclaw: { nativeConfigDirectory: selected } }
workspace.detectedAgents = [{ kind: 'openclaw', executable: '/native/openclaw' }]
await workspace.input.resolveProjectEnvironmentIdentities(directory, ['openclaw'])
await workspace.input.runAgent({ kind: 'openclaw', executable: '/native/openclaw' }, 'hello', directory, {})
assert.deepEqual(calls, [selected, selected])
})
test('MiMo captures the recent model provider credential for a project', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-recent-main-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -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', 'opencodereview']
const nativeConfigKinds = ['hermes', 'openclaw', 'codex', 'claude', 'pi', 'kimi', 'mimo', 'deepseek', 'workbuddy', 'opencode', 'gemini', 'zcode', 'qwen', 'opencodereview']
const props = defineProps({
+1
View File
@@ -11,6 +11,7 @@ export default {
'nativeConfig.invalid': 'The selected directory does not contain this Agent’s native configuration.',
'nativeConfig.sessionProfilesInvalid': 'Cannot safely switch configurations because saved session data is invalid or at its limit.',
'nativeConfig.expected.hermes': 'Expected config.yaml, auth.json, or .env.',
'nativeConfig.expected.openclaw': 'Expected openclaw.json or an agents directory.',
'nativeConfig.expected.codex': 'Expected config.toml or auth.json.',
'nativeConfig.expected.claude': 'Expected settings.json or .credentials.json.',
'nativeConfig.expected.pi': 'Expected settings.json, auth.json or models.json.',
+1
View File
@@ -11,6 +11,7 @@ export default {
'nativeConfig.invalid': '所选目录没有找到此 Agent 的原生配置文件。',
'nativeConfig.sessionProfilesInvalid': '无法安全切换原生配置:保存的会话数据无效或已达到容量上限。',
'nativeConfig.expected.hermes': '需要 config.yaml、auth.json 或 .env。',
'nativeConfig.expected.openclaw': '需要 openclaw.json 或 agents 目录。',
'nativeConfig.expected.codex': '需要 config.toml 或 auth.json。',
'nativeConfig.expected.claude': '需要 settings.json 或 .credentials.json。',
'nativeConfig.expected.pi': '需要 settings.json、auth.json 或 models.json。',