diff --git a/src/handlers/command.ts b/src/handlers/command.ts index 2b5657c..f94a3eb 100644 --- a/src/handlers/command.ts +++ b/src/handlers/command.ts @@ -1038,7 +1038,7 @@ export class CommandHandler { const projects = DirectoryPolicy.listAvailableProjects(knownDirs); if (projects.length === 0) { - await feishuClient.reply(messageId, '暂无可用项目\n管理员可通过 PROJECT_ALIASES 环境变量配置项目别名'); + await feishuClient.reply(messageId, DirectoryPolicy.buildProjectListEmptyMessage()); return; } diff --git a/src/utils/directory-policy.ts b/src/utils/directory-policy.ts index fd6ef5e..a2a8928 100644 --- a/src/utils/directory-policy.ts +++ b/src/utils/directory-policy.ts @@ -1,6 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; +import dotenv from 'dotenv'; import { directoryConfig } from '../config.js'; +import { configStore } from '../store/config-store.js'; export type DirectorySource = 'explicit' | 'alias' | 'chat_default' | 'env_default' | 'server_default'; @@ -56,6 +58,84 @@ interface DirectoryResolveOptions { const isWindows = process.platform === 'win32'; export class DirectoryPolicy { + private static detectAllowlistConfigMode(): { + mode: 'env' | 'db' | 'mixed' | 'unknown'; + envFile?: string; + } { + const envFile = process.env.OPENCODE_BRIDGE_ACTIVE_ENV_FILE?.trim(); + const migrated = configStore.isMigrated(); + + let envHasAllowlist = false; + if (envFile) { + try { + const content = fs.readFileSync(envFile, 'utf-8'); + const parsed = dotenv.parse(content); + envHasAllowlist = typeof parsed.ALLOWED_DIRECTORIES === 'string' + && parsed.ALLOWED_DIRECTORIES.trim().length > 0; + } catch { + // ignore + } + } + + if (envHasAllowlist && migrated) { + return { mode: 'mixed', envFile }; + } + + if (envHasAllowlist) { + return { mode: 'env', envFile }; + } + + if (migrated) { + return { mode: 'db' }; + } + + if (envFile) { + return { mode: 'env', envFile }; + } + + return { mode: 'unknown' }; + } + + private static buildAllowlistGuidance(): string { + return '请在 Web 管理面板的“核心行为 -> 工作目录与项目 -> 允许的目录白名单(ALLOWED_DIRECTORIES)”中添加或修改目录\n管理面板地址:http://localhost:4098'; + } + + private static buildAllowlistUserMessage( + rawPath: string, + variant: 'not_allowed' | 'missing_allowlist', + allowedDirectories: string[] + ): string { + const detected = this.detectAllowlistConfigMode(); + const sourceText = + detected.mode === 'env' ? '配置来源:.env' : + detected.mode === 'db' ? '配置来源:Web 管理面板 / SQLite' : + detected.mode === 'mixed' ? '配置来源:混合模式(当前 .env 优先)' : + '配置来源:未识别'; + const allowlistText = allowedDirectories.length > 0 + ? `当前允许目录:${allowedDirectories.join(' | ')}` + : '当前允许目录:未配置'; + const title = variant === 'missing_allowlist' + ? '未配置允许目录,禁止使用用户输入路径' + : '目录不在允许范围内'; + + return `${title}\n尝试目录:${rawPath}\n${sourceText}\n${allowlistText}\n${this.buildAllowlistGuidance()}`; + } + + static buildProjectListEmptyMessage(): string { + const detected = this.detectAllowlistConfigMode(); + const allowlist = directoryConfig.allowedDirectories; + const sourceText = + detected.mode === 'env' ? '配置来源:.env' : + detected.mode === 'db' ? '配置来源:Web 管理面板 / SQLite' : + detected.mode === 'mixed' ? '配置来源:混合模式(当前 .env 优先)' : + '配置来源:未识别'; + const allowlistText = allowlist.length > 0 + ? `当前允许目录:${allowlist.join(' | ')}` + : '当前允许目录:未配置'; + + return `暂无可用项目\n${sourceText}\n${allowlistText}\n${this.buildAllowlistGuidance()}\n也可通过 PROJECT_ALIASES 配置项目别名`; + } + // 解析并校验目录(九阶段) static resolve(options?: DirectoryResolveOptions): DirectoryResolveResult { const explicitDirectory = options?.explicitDirectory?.trim(); @@ -165,7 +245,7 @@ export class DirectoryPolicy { return { ok: false, code: 'not_allowed', - userMessage: '目录不在允许范围内', + userMessage: this.buildAllowlistUserMessage(raw, 'not_allowed', allowedDirectories), internalDetail: `不在允许范围: ${normalized}`, ...(source ? { source } : {}), raw, @@ -175,7 +255,7 @@ export class DirectoryPolicy { return { ok: false, code: 'explicit_requires_allowlist', - userMessage: '未配置允许目录,禁止使用用户输入路径', + userMessage: this.buildAllowlistUserMessage(raw, 'missing_allowlist', allowedDirectories), internalDetail: 'explicit 输入需要 ALLOWED_DIRECTORIES', raw, }; @@ -238,7 +318,7 @@ export class DirectoryPolicy { return { ok: false, code: 'realpath_not_allowed', - userMessage: '目录不在允许范围内', + userMessage: this.buildAllowlistUserMessage(raw, 'not_allowed', allowedDirectories), internalDetail: `realpath 超出允许范围: ${realpath}`, ...(source ? { source } : {}), raw, @@ -268,7 +348,7 @@ export class DirectoryPolicy { return { ok: false, code: 'git_root_not_allowed', - userMessage: '目录不在允许范围内', + userMessage: this.buildAllowlistUserMessage(raw, 'not_allowed', allowedDirectories), internalDetail: `git 根目录超出允许范围: ${gitRoot}`, ...(source ? { source } : {}), raw, diff --git a/tests/directory-policy.test.ts b/tests/directory-policy.test.ts index 8196f90..ca21046 100644 --- a/tests/directory-policy.test.ts +++ b/tests/directory-policy.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { DirectoryPolicy } from '../src/utils/directory-policy.js'; import { normalizePath, @@ -242,6 +245,13 @@ describe('DirectoryPolicy - Path Normalization and Security', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe('not_allowed'); + expect(result.userMessage).toContain('尝试目录:/not/allowed'); + expect(result.userMessage).toContain('配置来源'); + expect(result.userMessage).toContain('当前允许目录:/allowed'); + expect(result.userMessage).toContain('ALLOWED_DIRECTORIES'); + expect(result.userMessage).toContain('Web 管理面板'); + expect(result.userMessage).toContain('核心行为 -> 工作目录与项目'); + expect(result.userMessage).toContain('管理面板地址:http://localhost:4098'); } }); @@ -290,6 +300,47 @@ describe('DirectoryPolicy - Path Normalization and Security', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe('explicit_requires_allowlist'); + expect(result.userMessage).toContain('尝试目录:/explicit'); + expect(result.userMessage).toContain('配置来源'); + expect(result.userMessage).toContain('当前允许目录:未配置'); + expect(result.userMessage).toContain('ALLOWED_DIRECTORIES'); + expect(result.userMessage).toContain('Web 管理面板'); + expect(result.userMessage).toContain('核心行为 -> 工作目录与项目'); + expect(result.userMessage).toContain('管理面板地址:http://localhost:4098'); + } + }); + + it('应该在使用 .env 配置时统一引导到 Web 管理面板', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-bridge-env-')); + const envFile = path.join(tempDir, '.env'); + const previousEnvFile = process.env.OPENCODE_BRIDGE_ACTIVE_ENV_FILE; + fs.writeFileSync(envFile, 'ALLOWED_DIRECTORIES=/tmp/project\n', 'utf-8'); + process.env.OPENCODE_BRIDGE_ACTIVE_ENV_FILE = envFile; + + try { + const result = DirectoryPolicy.resolve({ + explicitDirectory: '/not/allowed', + allowedDirectories: ['/allowed'], + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.userMessage).toMatch(/配置来源:\.env|配置来源:混合模式(当前 \.env 优先)/); + expect(result.userMessage).toContain('尝试目录:/not/allowed'); + expect(result.userMessage).toContain('当前允许目录:/allowed'); + expect(result.userMessage).toContain('Web 管理面板'); + expect(result.userMessage).toContain('核心行为 -> 工作目录与项目 -> 允许的目录白名单(ALLOWED_DIRECTORIES)'); + expect(result.userMessage).toContain('管理面板地址:http://localhost:4098'); + expect(result.userMessage).not.toContain(envFile); + expect(result.userMessage).not.toContain('重启服务'); + } + } finally { + if (previousEnvFile === undefined) { + delete process.env.OPENCODE_BRIDGE_ACTIVE_ENV_FILE; + } else { + process.env.OPENCODE_BRIDGE_ACTIVE_ENV_FILE = previousEnvFile; + } + fs.rmSync(tempDir, { recursive: true, force: true }); } }); }); @@ -316,4 +367,16 @@ describe('DirectoryPolicy - Path Normalization and Security', () => { expect(result).toBe(false); }); }); + + describe('project list empty message', () => { + it('应该包含目录配置引导', () => { + const result = DirectoryPolicy.buildProjectListEmptyMessage(); + expect(result).toContain('暂无可用项目'); + expect(result).toContain('配置来源'); + expect(result).toContain('当前允许目录'); + expect(result).toContain('Web 管理面板'); + expect(result).toContain('管理面板地址:http://localhost:4098'); + expect(result).toContain('PROJECT_ALIASES'); + }); + }); });