Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/handlers/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
88 changes: 84 additions & 4 deletions src/utils/directory-policy.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/directory-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
}
});

Expand Down Expand Up @@ -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 });
}
});
});
Expand All @@ -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');
});
});
});