diff --git a/adapters/common/__tests__/adapter-client.test.ts b/adapters/common/__tests__/adapter-client.test.ts new file mode 100644 index 00000000..0e5f164b --- /dev/null +++ b/adapters/common/__tests__/adapter-client.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createAdapterClient } from '../adapter-client.js' +import { loadConfig } from '../config.js' + +const ADAPTERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') +const PLATFORMS = ['telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp'] as const +const HOME = fs.realpathSync(os.homedir()) + +const ORIGINAL_ENV = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + ADAPTER_ALLOWED_PROJECT_ROOTS: process.env.ADAPTER_ALLOWED_PROJECT_ROOTS, + ADAPTER_DEFAULT_PROJECT_DIR: process.env.ADAPTER_DEFAULT_PROJECT_DIR, + CLAUDE_ADAPTER_DEFAULT_WORK_DIR: process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR, + PWD: process.env.PWD, +} +const ORIGINAL_CWD = process.cwd() +const ORIGINAL_FETCH = globalThis.fetch + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + process.chdir(ORIGINAL_CWD) + globalThis.fetch = ORIGINAL_FETCH +}) + +/** Boot an adapter config from a throwaway config dir with a clean env. */ +function bootConfig(file: Record): ReturnType { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-client-cfg-')) + fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file)) + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS + delete process.env.ADAPTER_DEFAULT_PROJECT_DIR + delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + return loadConfig() +} + +/** What the bot would actually show for /projects, given what the server returns. */ +async function listedProjects( + client: { listRecentProjects: () => Promise<{ projectName: string }[]> }, + projects: { projectName: string; realPath: string }[], +): Promise { + globalThis.fetch = mock(() => Promise.resolve(Response.json({ projects }))) as any + return (await client.listRecentProjects()).map((p) => p.projectName) +} + +describe('createAdapterClient', () => { + // The regression that started #1191, now pinned behaviourally rather than by + // grepping the entrypoints. + it('keeps every project under home reachable when a default project is set', async () => { + const base = fs.mkdtempSync(path.join(HOME, '.cc-haha-test-')) + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-outside-')) + try { + const myApp = path.join(base, 'work', 'my-app') + const sibling = path.join(base, 'side', 'blog') + for (const dir of [myApp, sibling]) fs.mkdirSync(dir, { recursive: true }) + + for (const platform of PLATFORMS) { + const config = bootConfig({ defaultProjectDir: myApp }) + const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform]) + + expect(defaultWorkDir).toBe(fs.realpathSync(myApp)) + const names = await listedProjects(httpClient, [ + { projectName: 'my-app', realPath: myApp }, + { projectName: 'blog', realPath: sibling }, + { projectName: 'not-mine', realPath: outside }, + ]) + expect(names).toEqual(['my-app', 'blog']) + } + } finally { + fs.rmSync(base, { recursive: true, force: true }) + fs.rmSync(outside, { recursive: true, force: true }) + } + }) + + // A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd). Inheriting + // that as a boundary would allow the whole filesystem while the docs and the + // settings UI both promise "your home directory". + it('never inherits a filesystem root as the boundary', async () => { + process.chdir('/') + delete process.env.PWD + + for (const platform of PLATFORMS) { + const config = bootConfig({}) + const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform]) + + const names = await listedProjects(httpClient, [ + { projectName: 'etc', realPath: '/etc' }, + { projectName: 'home-project', realPath: HOME }, + ]) + expect(names).toEqual(['home-project']) + expect(defaultWorkDir).toBe(HOME) + } + }) + + // Narrowing the roots must not brick /new: the client rejects a workDir outside + // the boundary, and every adapter passes defaultWorkDir straight to createSession. + it('always yields a default work dir inside the allowed roots', async () => { + const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-')) + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-elsewhere-')) + try { + process.chdir('/') + delete process.env.PWD + + for (const platform of PLATFORMS) { + // Boundary narrowed to one dir, default project pointing somewhere else. + const config = bootConfig({ allowedProjectRoots: [allowed], defaultProjectDir: elsewhere }) + const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform]) + + expect(defaultWorkDir).toBe(fs.realpathSync(allowed)) + globalThis.fetch = mock(() => Promise.resolve(Response.json({ sessionId: 'ok' }))) as any + await expect(httpClient.createSession(defaultWorkDir)).resolves.toBe('ok') + } + } finally { + fs.rmSync(allowed, { recursive: true, force: true }) + fs.rmSync(elsewhere, { recursive: true, force: true }) + } + }) + + it('honours an explicitly narrowed boundary', async () => { + const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-')) + const denied = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-denied-')) + try { + const config = bootConfig({ allowedProjectRoots: [allowed] }) + const { httpClient } = createAdapterClient(config, config.feishu) + + const names = await listedProjects(httpClient, [ + { projectName: 'allowed', realPath: allowed }, + { projectName: 'denied', realPath: denied }, + { projectName: 'home', realPath: HOME }, + ]) + expect(names).toEqual(['allowed']) + } finally { + fs.rmSync(allowed, { recursive: true, force: true }) + fs.rmSync(denied, { recursive: true, force: true }) + } + }) +}) + +/** + * Structural guard for the five entrypoints. They boot a live bot on import + * (credentials are read and process.exit is called), so they cannot be imported + * in a test. The behaviour above is covered by exercising the factory directly; + * this only pins that each entrypoint actually delegates to it. + */ +describe('IM adapter entrypoint wiring', () => { + for (const platform of PLATFORMS) { + it(`${platform} builds its client through createAdapterClient`, () => { + const source = fs.readFileSync(path.join(ADAPTERS_DIR, platform, 'index.ts'), 'utf-8') + // Strip comments so a mention in prose cannot satisfy the assertions. + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1') + + expect(source).toMatch( + new RegExp(`createAdapterClient\\s*\\(\\s*config\\s*,\\s*config\\.${platform}\\s*\\)`), + ) + // Constructing a client here would bypass the resolved boundary entirely, + // which is exactly how all five adapters shared the #1191 defect. + expect(source).not.toMatch(/new\s+AdapterHttpClient/) + // Nor may an entrypoint re-derive the boundary or the work dir itself. + expect(source).not.toMatch(/resolveAllowedProjectRoots|getConfiguredWorkDir/) + }) + } +}) diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts index 3f8d0f20..0b3be66d 100644 --- a/adapters/common/__tests__/config.test.ts +++ b/adapters/common/__tests__/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { getConfiguredWorkDir, loadConfig } from '../config.js' +import { getConfiguredWorkDir, loadConfig, resolveAllowedProjectRoots } from '../config.js' describe('adapter config defaults', () => { const originalConfigDir = process.env.CLAUDE_CONFIG_DIR @@ -130,6 +130,189 @@ describe('adapter config defaults', () => { }) }) +describe('resolveAllowedProjectRoots', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const originalEnvRoots = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS + const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + const originalPwd = process.env.PWD + const home = fs.realpathSync(os.homedir()) + + afterEach(() => { + restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) + restoreEnv('ADAPTER_ALLOWED_PROJECT_ROOTS', originalEnvRoots) + restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir) + restoreEnv('PWD', originalPwd) + }) + + function withConfig(file: Record, run: (configDir: string) => T): T { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + try { + fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file)) + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS + return run(configDir) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + } + } + + // The #1191 regression: `defaultProjectDir` is the default work dir for NEW + // sessions, not the boundary. Deriving the only allowed root from it hid every + // other project from /projects on all five IM channels. + it('does not collapse the boundary onto the configured default project', () => { + const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-')) + try { + withConfig({ defaultProjectDir }, () => { + const config = loadConfig() + for (const platform of [config.telegram, config.feishu, config.wechat, config.dingtalk, config.whatsapp]) { + const roots = resolveAllowedProjectRoots(config, platform) + expect(roots).not.toEqual([fs.realpathSync(defaultProjectDir)]) + expect(roots).toContain(home) + expect(roots).toContain(fs.realpathSync(defaultProjectDir)) + } + }) + } finally { + fs.rmSync(defaultProjectDir, { recursive: true, force: true }) + } + }) + + it('defaults to the home directory so sibling projects stay reachable', () => { + withConfig({}, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toContain(home) + }) + }) + + it('uses explicitly configured global roots instead of the default', () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-a-')) + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-b-')) + try { + withConfig({ allowedProjectRoots: [rootA, rootB] }, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([ + fs.realpathSync(rootA), + fs.realpathSync(rootB), + ]) + }) + } finally { + fs.rmSync(rootA, { recursive: true, force: true }) + fs.rmSync(rootB, { recursive: true, force: true }) + } + }) + + it('lets a platform narrow the global roots', () => { + const globalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-global-')) + const feishuRoot = fs.mkdtempSync(path.join(globalRoot, 'feishu-')) + try { + withConfig({ allowedProjectRoots: [globalRoot], feishu: { allowedProjectRoots: [feishuRoot] } }, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(feishuRoot)]) + // Other platforms keep the global roots. + expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(globalRoot)]) + }) + } finally { + fs.rmSync(globalRoot, { recursive: true, force: true }) + } + }) + + // A relative entry would resolve against the sidecar's cwd — "/" for a + // GUI-launched app — making the boundary depend on how the app was started. + it('rejects relative roots', () => { + const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-')) + try { + withConfig({ allowedProjectRoots: ['..', 'relative/path', realRoot] }, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot)]) + }) + } finally { + fs.rmSync(realRoot, { recursive: true, force: true }) + } + }) + + it('does not warn about duplicates as if they were missing', () => { + const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-')) + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')) } + try { + withConfig({ allowedProjectRoots: [realRoot, realRoot, '~', os.homedir()] }, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([ + fs.realpathSync(realRoot), + home, + ]) + }) + expect(warnings.filter((line) => line.includes('do not exist') || line.includes('does not exist'))) + .toEqual([]) + } finally { + console.warn = originalWarn + fs.rmSync(realRoot, { recursive: true, force: true }) + } + }) + + it('expands ~ and drops entries that do not exist', () => { + const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-')) + try { + withConfig({ allowedProjectRoots: [realRoot, path.join(os.tmpdir(), 'definitely-missing-root'), '~'] }, () => { + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot), home]) + }) + } finally { + fs.rmSync(realRoot, { recursive: true, force: true }) + } + }) + + // Failing closed here would brick every IM command on a typo. The pairing gate + // is the primary authorization control; these roots are defense-in-depth. + it('falls back to the default when no configured root exists', () => { + withConfig({ allowedProjectRoots: [path.join(os.tmpdir(), 'missing-a'), path.join(os.tmpdir(), 'missing-b')] }, () => { + const config = loadConfig() + const roots = resolveAllowedProjectRoots(config, config.feishu) + expect(roots).toContain(home) + expect(roots.length).toBeGreaterThan(0) + }) + }) + + it('reads roots from ADAPTER_ALLOWED_PROJECT_ROOTS for standalone runs', () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-a-')) + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-b-')) + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + try { + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = [rootA, rootB].join(path.delimiter) + + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([ + fs.realpathSync(rootA), + fs.realpathSync(rootB), + ]) + } finally { + fs.rmSync(rootA, { recursive: true, force: true }) + fs.rmSync(rootB, { recursive: true, force: true }) + fs.rmSync(configDir, { recursive: true, force: true }) + } + }) + + it('lets the env override win over both file scopes', () => { + const envRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-')) + const fileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-file-root-')) + try { + withConfig( + { allowedProjectRoots: [fileRoot], feishu: { allowedProjectRoots: [fileRoot] } }, + () => { + process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = envRoot + const config = loadConfig() + expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(envRoot)]) + expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(envRoot)]) + }, + ) + } finally { + fs.rmSync(envRoot, { recursive: true, force: true }) + fs.rmSync(fileRoot, { recursive: true, force: true }) + } + }) +}) + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key] diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index d2ab0b17..13f647ac 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -85,6 +85,50 @@ describe('AdapterHttpClient', () => { } }) + // #1191: /projects showed only the default project on every IM channel because + // the allowed root was the default work dir itself. With the boundary resolved + // from the user's home instead, sibling projects must survive the filter. + it('keeps sibling projects that live outside the default work dir', async () => { + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-home-')) + try { + const defaultWorkDir = path.join(homeRoot, 'work', 'my-app') + const sibling = path.join(homeRoot, 'work', 'other-app') + const elsewhere = path.join(homeRoot, 'side', 'blog') + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'im-outside-')) + for (const dir of [defaultWorkDir, sibling, elsewhere]) fs.mkdirSync(dir, { recursive: true }) + + // The roots an adapter now gets from resolveAllowedProjectRoots(): the home + // directory, not the default work dir. + client = new AdapterHttpClient('ws://127.0.0.1:3456', { + allowedProjectRoots: [homeRoot, defaultWorkDir], + }) + globalThis.fetch = mock(() => + Promise.resolve(Response.json({ + projects: [ + { projectName: 'my-app', realPath: defaultWorkDir, sessionCount: 9 }, + { projectName: 'other-app', realPath: sibling, sessionCount: 4 }, + { projectName: 'blog', realPath: elsewhere, sessionCount: 2 }, + { projectName: 'not-mine', realPath: outside, sessionCount: 1 }, + ], + })) + ) as any + + const projects = await client.listRecentProjects() + expect(projects.map((p) => p.projectName)).toEqual(['my-app', 'other-app', 'blog']) + + // Picking any of them by name must work too — matchProject shares the filter. + await expect(client.matchProject('blog')).resolves.toMatchObject({ + project: { projectName: 'blog' }, + }) + // The boundary still holds for anything outside it. + await expect(client.matchProject('not-mine')).resolves.toEqual({}) + + fs.rmSync(outside, { recursive: true, force: true }) + } finally { + fs.rmSync(homeRoot, { recursive: true, force: true }) + } + }) + it('filters recent projects before index, name, and fuzzy matching', async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) const allowedDir = fs.mkdtempSync(path.join(rootDir, 'allowed-')) diff --git a/adapters/common/adapter-client.ts b/adapters/common/adapter-client.ts new file mode 100644 index 00000000..adfcd390 --- /dev/null +++ b/adapters/common/adapter-client.ts @@ -0,0 +1,34 @@ +import { + resolveAdapterWorkspace, + type AdapterConfig, + type AdapterPlatformConfig, +} from './config.js' +import { AdapterHttpClient } from './http-client.js' + +export type AdapterWorkspace = { + httpClient: AdapterHttpClient + /** Where a new IM session starts. Guaranteed to sit inside the allowed roots. */ + defaultWorkDir: string +} + +/** + * Build the HTTP client and the default work dir for an IM adapter. + * + * Every adapter entrypoint goes through here instead of constructing the client + * itself. The five entrypoints previously repeated the wiring, and all five + * repeated the same defect (#1191): they passed the default work dir as the only + * allowed project root, so /projects listed a single project. Keeping the + * construction in one importable place makes that class of mistake unreachable + * without editing this file, and makes it testable — the entrypoints boot a live + * bot on import and cannot be exercised directly. + */ +export function createAdapterClient( + config: AdapterConfig, + platformConfig: AdapterPlatformConfig, +): AdapterWorkspace { + const { defaultWorkDir, allowedProjectRoots } = resolveAdapterWorkspace(config, platformConfig) + return { + httpClient: new AdapterHttpClient(config.serverUrl, { allowedProjectRoots }), + defaultWorkDir, + } +} diff --git a/adapters/common/config.ts b/adapters/common/config.ts index 5b095a7b..81f819bf 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -25,6 +25,7 @@ export type TelegramConfig = { allowedUsers: number[] pairedUsers: PairedUser[] defaultWorkDir: string + allowedProjectRoots: string[] } export type FeishuConfig = { @@ -36,6 +37,7 @@ export type FeishuConfig = { pairedUsers: PairedUser[] defaultWorkDir: string streamingCard: boolean + allowedProjectRoots: string[] } export type WechatConfig = { @@ -46,6 +48,7 @@ export type WechatConfig = { allowedUsers: string[] pairedUsers: PairedUser[] defaultWorkDir: string + allowedProjectRoots: string[] } export type DingtalkConfig = { @@ -56,6 +59,7 @@ export type DingtalkConfig = { defaultWorkDir: string endpoint: string permissionCardTemplateId: string + allowedProjectRoots: string[] } export type WhatsAppConfig = { @@ -64,12 +68,14 @@ export type WhatsAppConfig = { allowedUsers: string[] pairedUsers: PairedUser[] defaultWorkDir: string + allowedProjectRoots: string[] } export type AdapterConfig = { serverUrl: string defaultProjectDir: string pairing: PairingState + allowedProjectRoots: string[] telegram: TelegramConfig feishu: FeishuConfig wechat: WechatConfig @@ -121,11 +127,15 @@ export function loadConfig(): AdapterConfig { expiresAt: pairing.expiresAt ?? null, createdAt: pairing.createdAt ?? null, }, + // File scope only. ADAPTER_ALLOWED_PROJECT_ROOTS is applied by + // resolveAllowedProjectRoots so this field keeps one meaning. + allowedProjectRoots: readProjectRoots(file.allowedProjectRoots), telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '', allowedUsers: tg.allowedUsers ?? [], pairedUsers: tg.pairedUsers ?? [], defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir, + allowedProjectRoots: readProjectRoots(tg.allowedProjectRoots), }, feishu: { appId: process.env.FEISHU_APP_ID || fs_.appId || '', @@ -136,6 +146,7 @@ export function loadConfig(): AdapterConfig { pairedUsers: fs_.pairedUsers ?? [], defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir, streamingCard: fs_.streamingCard ?? false, + allowedProjectRoots: readProjectRoots(fs_.allowedProjectRoots), }, wechat: { accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '', @@ -145,6 +156,7 @@ export function loadConfig(): AdapterConfig { allowedUsers: wc.allowedUsers ?? [], pairedUsers: wc.pairedUsers ?? [], defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir, + allowedProjectRoots: readProjectRoots(wc.allowedProjectRoots), }, dingtalk: { clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '', @@ -154,6 +166,7 @@ export function loadConfig(): AdapterConfig { defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir, endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com', permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '', + allowedProjectRoots: readProjectRoots(dt.allowedProjectRoots), }, whatsapp: { accountJid: process.env.WHATSAPP_ACCOUNT_JID || wa.accountJid || '', @@ -161,6 +174,7 @@ export function loadConfig(): AdapterConfig { allowedUsers: wa.allowedUsers ?? [], pairedUsers: wa.pairedUsers ?? [], defaultWorkDir: wa.defaultWorkDir || fallbackWorkDir, + allowedProjectRoots: readProjectRoots(wa.allowedProjectRoots), }, } } @@ -169,21 +183,167 @@ export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: Adap return config.defaultProjectDir || platformConfig.defaultWorkDir } +/** + * Resolve the directories an IM adapter is allowed to reach. + * + * This is deliberately NOT derived from `defaultWorkDir` (#1191). That field is + * documented as the *default* work dir for new IM sessions, not a boundary, and + * using it as the sole allowed root broke both directions: + * + * - configured → /projects listed only that one project, and picking any other + * recent project by name or path failed; + * - blank → it falls back to PWD/cwd(), which for a Finder-launched .app + * is "/", so the boundary silently allowed the entire filesystem. + * + * Precedence: ADAPTER_ALLOWED_PROJECT_ROOTS > platform-specific roots > global + * roots > default (home ∪ default work dir). The pairing gate is the primary + * authorization control; these roots are defense-in-depth, so a misconfigured + * value falls back to the default with a warning instead of bricking the bot. + * + * Explicitly configured roots are honoured verbatim — if someone types "/" they + * own the machine and mean it. The *default* branch refuses to inherit such a + * root, because that is how the boundary silently became vacuous before. + */ +export function resolveAllowedProjectRoots( + config: AdapterConfig, + platformConfig: AdapterPlatformConfig, +): string[] { + // Env wins over both file scopes, matching how every other field in this + // module resolves. + const configured = readEnvProjectRoots() + ?? (platformConfig.allowedProjectRoots.length > 0 + ? platformConfig.allowedProjectRoots + : config.allowedProjectRoots) + + if (configured.length > 0) { + const candidates = configured.map(resolveExistingDirectory) + // Count the misses before dedup — duplicates are not missing directories. + const missing = candidates.filter((value) => !value).length + const resolved = dedupePaths(candidates) + if (resolved.length > 0) { + if (missing > 0) { + console.warn( + missing === 1 + ? '[Config] Ignoring 1 allowedProjectRoots entry that does not exist' + : `[Config] Ignoring ${missing} allowedProjectRoots entries that do not exist`, + ) + } + return resolved + } + console.warn( + '[Config] None of the configured allowedProjectRoots exist; ' + + 'falling back to the default roots (home directory + default project dir)', + ) + } + + const home = resolveExistingDirectory(os.homedir()) + const defaults = dedupePaths([ + home, + // Only inherit the default work dir as a boundary when it is a real project + // directory. "/" and "/Users" reach every project on the machine, so taking + // them from the PWD/cwd() fallback would make the boundary meaningless. + usableAsBoundary(resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig))), + ]) + if (defaults.length > 0) return defaults + // Only reachable if the home directory itself does not resolve. The adapter + // client drops unresolvable roots, so this is a best effort, not a guarantee. + return [os.homedir()] +} + +/** + * Directories the IM boundary must never inherit implicitly: a filesystem root, + * or any strict ancestor of the home directory (`/`, `/Users`, `/home`). + */ +function usableAsBoundary(dir: string | null): string | null { + if (!dir) return null + if (path.parse(dir).root === dir) return null + return isStrictAncestor(dir, os.homedir()) ? null : dir +} + +function isStrictAncestor(candidate: string, target: string): boolean { + const relative = path.relative(candidate, target) + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +/** + * The work dir a new IM session starts in, paired with the boundary it must sit + * inside. Resolving them together is the point: the two are configured + * separately, and a default project outside the allowed roots would otherwise + * make every `/new` (and every first message in a fresh chat) fail the client's + * own boundary check. + */ +export function resolveAdapterWorkspace( + config: AdapterConfig, + platformConfig: AdapterPlatformConfig, +): { defaultWorkDir: string; allowedProjectRoots: string[] } { + const allowedProjectRoots = resolveAllowedProjectRoots(config, platformConfig) + const configured = resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig)) + + if (configured && isPathWithinRoots(configured, allowedProjectRoots)) { + return { defaultWorkDir: configured, allowedProjectRoots } + } + + const fallback = allowedProjectRoots[0] ?? os.homedir() + if (configured) { + console.warn( + `[Config] Default project ${configured} is outside the allowed project roots; ` + + `new sessions will start in ${fallback}`, + ) + } + return { defaultWorkDir: fallback, allowedProjectRoots } +} + +function isPathWithinRoots(target: string, roots: string[]): boolean { + return roots.some((root) => { + const relative = path.relative(root, target) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) + }) +} + +function readProjectRoots(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) +} + +function readEnvProjectRoots(): string[] | null { + const raw = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS?.trim() + if (!raw) return null + const roots = readProjectRoots(raw.split(path.delimiter)) + // A delimiter-only value (an unset "$A:$B" in a launcher script) must not read + // as "the env configured an empty boundary" and discard the file config. + return roots.length > 0 ? roots : null +} + +function dedupePaths(values: (string | null)[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + if (!value || seen.has(value)) continue + seen.add(value) + result.push(value) + } + return result +} + function resolveUserDefaultWorkDir(): string { const candidates = [ process.env.ADAPTER_DEFAULT_PROJECT_DIR, process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR, process.env.PWD, process.cwd(), - os.homedir(), ] for (const candidate of candidates) { - const resolved = resolveExistingDirectory(candidate) + // A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd), which is + // useless as a place to start a session and unusable as a boundary. + const resolved = usableAsBoundary(resolveExistingDirectory(candidate)) if (resolved) return resolved } - return os.homedir() + return resolveExistingDirectory(os.homedir()) ?? os.homedir() } function resolveExistingDirectory(value: string | undefined): string | null { @@ -196,6 +356,10 @@ function resolveExistingDirectory(value: string | undefined): string | null { ? path.join(os.homedir(), trimmed.slice(2)) : trimmed + // Relative entries would resolve against the sidecar's cwd ("/" for a packaged + // app), making the boundary depend on how the app was launched. + if (!path.isAbsolute(expanded)) return null + try { const realPath = fs.realpathSync(expanded) return fs.statSync(realPath).isDirectory() ? realPath : null diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index c74f15ca..cd2c1869 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b import { MessageDedup } from '../common/message-dedup.js' import { MessageBuffer } from '../common/message-buffer.js' import { enqueue } from '../common/chat-queue.js' -import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js' import { formatPermissionDecisionStatus, @@ -20,7 +20,8 @@ import { type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { type RecentProject } from '../common/http-client.js' +import { createAdapterClient } from '../common/adapter-client.js' import { restoreStoredSessionBinding } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' @@ -54,12 +55,11 @@ if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.') process.exit(1) } -const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk) +const { httpClient, defaultWorkDir } = createAdapterClient(config, config.dingtalk) const bridge = new WsBridge(config.serverUrl, 'dingtalk') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) const attachmentStore = new AttachmentStore() const media = new DingTalkMediaService(attachmentStore) const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 566fe338..2f588dde 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -13,7 +13,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b import { MessageDedup } from '../common/message-dedup.js' import { StreamingCard } from './streaming-card.js' import { enqueue } from '../common/chat-queue.js' -import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, @@ -26,7 +26,8 @@ import { type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { type RecentProject } from '../common/http-client.js' +import { createAdapterClient } from '../common/adapter-client.js' import { restoreStoredSessionBinding } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { extractInboundPayload } from './extract-payload.js' @@ -56,8 +57,7 @@ const larkClient = new Lark.Client({ const bridge = new WsBridge(config.serverUrl, 'feishu') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const defaultWorkDir = getConfiguredWorkDir(config, config.feishu) -const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) +const { httpClient, defaultWorkDir } = createAdapterClient(config, config.feishu) // Attachment plumbing — shared by inbound (download) and outbound (upload) paths. const attachmentStore = new AttachmentStore() diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index cba2ed8e..4dd81779 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage } from '../common/ws-bridge.js' import { MessageBuffer } from '../common/message-buffer.js' import { MessageDedup } from '../common/message-dedup.js' import { enqueue } from '../common/chat-queue.js' -import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { loadConfig } from '../common/config.js' import { formatImStatus, formatPermissionRequest, @@ -31,7 +31,7 @@ import { type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' +import { createAdapterClient } from '../common/adapter-client.js' import { restoreStoredSessionBinding } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { TelegramMediaService } from './media.js' @@ -59,8 +59,7 @@ const bot = new Bot(config.telegram.botToken) const bridge = new WsBridge(config.serverUrl, 'tg') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const defaultWorkDir = getConfiguredWorkDir(config, config.telegram) -const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) +const { httpClient, defaultWorkDir } = createAdapterClient(config, config.telegram) const attachmentStore = new AttachmentStore() const media = new TelegramMediaService(bot, attachmentStore) attachmentStore.gc().catch((err) => { diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index 16dfe468..15db8417 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -3,7 +3,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b import { MessageDedup } from '../common/message-dedup.js' import { MessageBuffer } from '../common/message-buffer.js' import { enqueue } from '../common/chat-queue.js' -import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, @@ -16,7 +16,7 @@ import { parsePermissionCommand, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' +import { createAdapterClient } from '../common/adapter-client.js' import { restoreStoredSessionBinding } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' @@ -48,8 +48,7 @@ const botToken = config.wechat.botToken const bridge = new WsBridge(config.serverUrl, 'wechat') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const defaultWorkDir = getConfiguredWorkDir(config, config.wechat) -const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) +const { httpClient, defaultWorkDir } = createAdapterClient(config, config.wechat) const attachmentStore = new AttachmentStore() const media = new WechatMediaService(attachmentStore) const pendingProjectSelection = new Map() diff --git a/adapters/whatsapp/index.ts b/adapters/whatsapp/index.ts index 42e7ece6..d1d07065 100644 --- a/adapters/whatsapp/index.ts +++ b/adapters/whatsapp/index.ts @@ -13,7 +13,7 @@ import { import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' import { MessageDedup } from '../common/message-dedup.js' import { enqueue } from '../common/chat-queue.js' -import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, @@ -26,7 +26,7 @@ import { type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' +import { createAdapterClient } from '../common/adapter-client.js' import { restoreStoredSessionBinding } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' @@ -62,8 +62,7 @@ if (!hasWhatsAppAuth(authDir)) { const bridge = new WsBridge(config.serverUrl, 'whatsapp') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const defaultWorkDir = getConfiguredWorkDir(config, config.whatsapp) -const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) +const { httpClient, defaultWorkDir } = createAdapterClient(config, config.whatsapp) const attachmentStore = new AttachmentStore() attachmentStore.gc().catch((err) => { console.warn('[WhatsApp] AttachmentStore.gc failed:', err instanceof Error ? err.message : err) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fdf5ba91..5562cd11 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -865,6 +865,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.adapters.defaultProject': 'Default Project', 'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot uses the current user working directory.', 'settings.adapters.clearDefaultProject': 'Clear default project', + 'settings.adapters.allowedRoots': 'Allowed project directories', + 'settings.adapters.allowedRootsHint': 'The boundary for IM bots: they can only list, open and start sessions in projects inside these directories. Leave empty to use the default.', + 'settings.adapters.allowedRootsOverridden': 'Overridden for {platforms} by a per-platform setting in adapters.json — changes here do not affect {platforms}.', + 'settings.adapters.allowedRootsDefault': 'Default: your home directory (plus the default project, if it is outside home).', + 'settings.adapters.removeAllowedRoot': 'Remove', 'settings.adapters.streamingCard': 'Streaming Card Mode', 'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience', 'settings.adapters.serverUrl': 'Server URL', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 6513b80e..5f4eedbd 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -867,6 +867,11 @@ export const jp: Record = { 'settings.adapters.defaultProject': 'デフォルトプロジェクト', 'settings.adapters.defaultProjectHint': '新しい IM セッションのデフォルト作業ディレクトリ。空欄の場合、ボットは現在のユーザーの作業ディレクトリを使用します。', 'settings.adapters.clearDefaultProject': 'デフォルトプロジェクトをクリア', + 'settings.adapters.allowedRoots': 'アクセスを許可するプロジェクトディレクトリ', + 'settings.adapters.allowedRootsHint': 'IM ボットの境界です。これらのディレクトリ内のプロジェクトのみ一覧表示・オープン・セッション開始ができます。空欄の場合は既定値を使用します。', + 'settings.adapters.allowedRootsOverridden': '{platforms} は adapters.json で個別に設定されているため、ここでの変更は {platforms} には反映されません。', + 'settings.adapters.allowedRootsDefault': '既定:ホームディレクトリ(既定プロジェクトがホーム外にある場合はそれも含みます)。', + 'settings.adapters.removeAllowedRoot': '削除', 'settings.adapters.streamingCard': 'ストリーミングカードモード', 'settings.adapters.streamingCardDesc': 'より快適な体験のためにカードをリアルタイムで更新します', 'settings.adapters.serverUrl': 'サーバー URL', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 1fa62640..cbb45279 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -867,6 +867,11 @@ export const kr: Record = { 'settings.adapters.defaultProject': '기본 프로젝트', 'settings.adapters.defaultProjectHint': '새 IM 세션의 기본 작업 디렉터리입니다. 비어 있으면 봇은 현재 사용자 작업 디렉터리를 사용합니다.', 'settings.adapters.clearDefaultProject': '기본 프로젝트 지우기', + 'settings.adapters.allowedRoots': '접근을 허용할 프로젝트 디렉터리', + 'settings.adapters.allowedRootsHint': 'IM 봇의 접근 경계입니다. 이 디렉터리 안의 프로젝트만 목록에 표시하고 열거나 세션을 시작할 수 있습니다. 비워 두면 기본값을 사용합니다.', + 'settings.adapters.allowedRootsOverridden': '{platforms} 은(는) adapters.json에서 개별 설정되어 있어 여기서의 변경이 {platforms} 에는 적용되지 않습니다.', + 'settings.adapters.allowedRootsDefault': '기본값: 홈 디렉터리(기본 프로젝트가 홈 밖에 있으면 함께 포함).', + 'settings.adapters.removeAllowedRoot': '제거', 'settings.adapters.streamingCard': '스트리밍 카드 모드', 'settings.adapters.streamingCardDesc': '더 나은 환경을 위해 카드를 실시간으로 업데이트합니다', 'settings.adapters.serverUrl': '서버 URL', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 7fec99cd..f5fe27fc 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -866,6 +866,11 @@ export const zh: Record = { 'settings.adapters.defaultProject': '預設專案', 'settings.adapters.defaultProjectHint': '新 IM 會話的預設工作目錄。留空則使用當前使用者工作目錄。', 'settings.adapters.clearDefaultProject': '清除預設專案', + 'settings.adapters.allowedRoots': '允許存取的專案目錄', + 'settings.adapters.allowedRootsHint': 'IM 機器人的存取邊界:只能列出、開啟並在這些目錄內的專案裡開啟工作階段。留空則使用預設值。', + 'settings.adapters.allowedRootsOverridden': '{platforms} 在 adapters.json 裡單獨設定了目錄,這裡的變更對 {platforms} 不會生效。', + 'settings.adapters.allowedRootsDefault': '預設:你的主目錄(如果「預設專案」在主目錄之外,也一併包含)。', + 'settings.adapters.removeAllowedRoot': '移除', 'settings.adapters.streamingCard': '流式卡片模式', 'settings.adapters.streamingCardDesc': '實時更新訊息內容,體驗更好', 'settings.adapters.serverUrl': '伺服器地址', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1d5bfb78..df8c18ff 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -866,6 +866,11 @@ export const zh: Record = { 'settings.adapters.defaultProject': '默认项目', 'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则使用当前用户工作目录。', 'settings.adapters.clearDefaultProject': '清空默认项目', + 'settings.adapters.allowedRoots': '允许访问的项目目录', + 'settings.adapters.allowedRootsHint': 'IM 机器人的访问边界:只能列出、打开并在这些目录内的项目里开会话。留空则使用默认值。', + 'settings.adapters.allowedRootsOverridden': '{platforms} 在 adapters.json 里单独配置了目录,这里的改动对 {platforms} 不生效。', + 'settings.adapters.allowedRootsDefault': '默认:你的主目录(如果「默认项目」在主目录之外,也一并包含)。', + 'settings.adapters.removeAllowedRoot': '移除', 'settings.adapters.streamingCard': '流式卡片模式', 'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好', 'settings.adapters.serverUrl': '服务器地址', diff --git a/desktop/src/pages/AdapterSettings.test.tsx b/desktop/src/pages/AdapterSettings.test.tsx index 803f56b8..6c771fd7 100644 --- a/desktop/src/pages/AdapterSettings.test.tsx +++ b/desktop/src/pages/AdapterSettings.test.tsx @@ -60,6 +60,76 @@ describe('AdapterSettings IM setup entry', () => { }) }) +// #1191: the access boundary is its own setting, separate from the default +// project, and it round-trips through the config patch. +describe('AdapterSettings allowed project roots', () => { + it('explains the default when no roots are configured', () => { + renderAdapterSettings({}) + + expect(screen.getByText('Allowed project directories')).toBeInTheDocument() + expect( + screen.getByText('Default: your home directory (plus the default project, if it is outside home).'), + ).toBeInTheDocument() + }) + + it('lists configured roots and saves them after removing one', async () => { + const updateConfig = vi.fn(async (_patch: Partial) => {}) + renderAdapterSettings( + { allowedProjectRoots: ['/Users/me/work', '/Users/me/side'] }, + { updateConfig }, + ) + + expect(screen.getByText('/Users/me/work')).toBeInTheDocument() + expect(screen.getByText('/Users/me/side')).toBeInTheDocument() + + const sideRow = screen.getByText('/Users/me/side').closest('li')! + fireEvent.click(within(sideRow).getByRole('button', { name: 'Remove' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(updateConfig).toHaveBeenCalledTimes(1) + }) + expect(updateConfig.mock.calls[0]![0]).toMatchObject({ + allowedProjectRoots: ['/Users/me/work'], + }) + }) + + // A platform-level list replaces the global one, so a save here would silently + // not apply to that platform. + it('warns when a platform overrides the global roots', () => { + renderAdapterSettings({ + allowedProjectRoots: ['/Users/me/work'], + whatsapp: { allowedProjectRoots: ['/Users/me/work/sandbox'] }, + }) + + expect( + screen.getByText( + 'Overridden for WhatsApp by a per-platform setting in adapters.json — changes here do not affect WhatsApp.', + ), + ).toBeInTheDocument() + }) + + it('shows no override warning when only the global roots are set', () => { + renderAdapterSettings({ allowedProjectRoots: ['/Users/me/work'] }) + + expect(screen.queryByText(/per-platform setting/)).not.toBeInTheDocument() + }) + + it('keeps the default when nothing is configured instead of pinning the default project', async () => { + const updateConfig = vi.fn(async (_patch: Partial) => {}) + renderAdapterSettings({ defaultProjectDir: '/Users/me/work/my-app' }, { updateConfig }) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(updateConfig).toHaveBeenCalledTimes(1) + }) + const patch = updateConfig.mock.calls[0]![0] + expect(patch.defaultProjectDir).toBe('/Users/me/work/my-app') + expect(patch.allowedProjectRoots).toEqual([]) + }) +}) + describe('AdapterSettings Feishu onboarding', () => { it('shows the documented one-click Feishu bot link before credentials are configured', () => { renderAdapterSettings({}) diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index a5f316df..06a10a43 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -41,6 +41,9 @@ export function AdapterSettings() { // Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释), // 桌面端用 sidecar env var 注入动态端口。 const [defaultProjectDir, setDefaultProjectDir] = useState('') + // Which directories the IM bots may reach at all. Empty = the built-in default + // (home directory + default project), which is what restores /projects (#1191). + const [allowedProjectRoots, setAllowedProjectRoots] = useState([]) // Telegram const [tgBotToken, setTgBotToken] = useState('') @@ -92,6 +95,11 @@ export function AdapterSettings() { const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle') const [saveError, setSaveError] = useState('') + // Platforms whose own allowedProjectRoots replace the global list below. + const platformsWithOwnRoots = (['telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp'] as const) + .filter((platform) => (config[platform]?.allowedProjectRoots?.length ?? 0) > 0) + .map((platform) => t(`settings.adapters.${platform}` as const)) + // Pairing const [pairingCode, setPairingCode] = useState(null) const [isGenerating, setIsGenerating] = useState(false) @@ -106,6 +114,7 @@ export function AdapterSettings() { // Sync form state when config is loaded useEffect(() => { setDefaultProjectDir(config.defaultProjectDir ?? '') + setAllowedProjectRoots(config.allowedProjectRoots ?? []) setTgBotToken(config.telegram?.botToken ?? '') setTgAllowedUsers(config.telegram?.allowedUsers?.join(', ') ?? '') setFsAppId(config.feishu?.appId ?? '') @@ -259,6 +268,7 @@ export function AdapterSettings() { try { const patch: Record = { defaultProjectDir, + allowedProjectRoots, } const tgUsers = tgAllowedUsers @@ -613,6 +623,61 @@ export function AdapterSettings() {

+ {/* Allowed project roots —— 这是 IM 通道真正的边界。刻意和「默认项目」分开: + 「默认项目」只决定新会话开在哪,一度被当成唯一允许的根目录,导致 /projects + 只剩下那一个项目(#1191)。留空 = 主目录,足以覆盖绝大多数用法。 */} +
+ + {allowedProjectRoots.length > 0 ? ( +
    + {allowedProjectRoots.map((root) => ( +
  • + {root} + +
  • + ))} +
+ ) : ( +

+ {t('settings.adapters.allowedRootsDefault')} +

+ )} +
+ { + if (!dir) return + setAllowedProjectRoots((prev) => (prev.includes(dir) ? prev : [...prev, dir])) + }} + /> +
+

+ {t('settings.adapters.allowedRootsHint')} +

+ {/* A platform-level list replaces the global one, and the docs teach + hand-editing adapters.json — so say it, rather than letting a save + here look like it applied everywhere. */} + {platformsWithOwnRoots.length > 0 && ( +

+ {t('settings.adapters.allowedRootsOverridden', { + platforms: platformsWithOwnRoots.join(', '), + })} +

+ )} +
+ {/* IM Adapter Tabs */}
diff --git a/desktop/src/types/adapter.ts b/desktop/src/types/adapter.ts index 34a4ad3d..65140fd8 100644 --- a/desktop/src/types/adapter.ts +++ b/desktop/src/types/adapter.ts @@ -13,12 +13,14 @@ export type PairingState = { export type AdapterFileConfig = { serverUrl?: string defaultProjectDir?: string + allowedProjectRoots?: string[] pairing?: PairingState telegram?: { botToken?: string allowedUsers?: number[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } feishu?: { appId?: string @@ -28,6 +30,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] streamingCard?: boolean } wechat?: { @@ -38,6 +41,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } dingtalk?: { clientId?: string @@ -45,6 +49,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] endpoint?: string permissionCardTemplateId?: string } @@ -54,5 +59,6 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } } diff --git a/docs/desktop/remote.md b/docs/desktop/remote.md index c141e712..85e3dca8 100644 --- a/docs/desktop/remote.md +++ b/docs/desktop/remote.md @@ -87,7 +87,8 @@ H5 默认关闭,它也不是公开服务。开启前先确认你在自己信 ### 其他设置 -- **默认项目** — 新 IM 会话用哪个工作目录。留空则用当前用户工作目录。 +- **默认项目** — 新 IM 会话用哪个工作目录。留空则用当前用户工作目录。它只是个起点,不限制机器人能访问哪些项目。 +- **允许访问的项目目录** — 机器人的访问边界,`/projects` 只会列出这些目录里的项目。留空则默认为你的主目录(外加主目录之外的「默认项目」)。 - **流式卡片模式** — 实时更新消息内容,读起来更像在看它打字。 - **权限请求** — 钉钉可以配互动卡片模板 ID,用卡片按钮授权;不配的话所有平台都用 `/allow`、`/always`、`/deny` 文本命令。 diff --git a/docs/im/index.md b/docs/im/index.md index aba81f9c..63790bbe 100644 --- a/docs/im/index.md +++ b/docs/im/index.md @@ -60,6 +60,33 @@ order: 0 同一个 IM 聊天窗口后续的消息会复用同一条会话,桌面端重启后也能接回去。想换目录发 `/new`,想清空上下文但保留项目发 `/clear`。 +## 允许访问的项目目录决定它能碰哪些项目 + +「默认项目」只决定新会话开在哪,**不是**访问边界。真正的边界是「允许访问的项目目录」:机器人只能列出、打开并在这些目录内的项目里开会话,`/projects`、`/sessions` 和按名字、绝对路径选项目都受它约束。 + +留空就是默认值——你的主目录(如果「默认项目」是主目录之外的某个具体项目目录,也一并包含)。绝大多数人不需要动它。想收紧到某几个目录,就在设置里把它们加进列表。 + +配置写在 `~/.claude/adapters.json`,也可以按平台单独收紧: + +```json +{ + "allowedProjectRoots": ["~/work", "~/side"], + "whatsapp": { "allowedProjectRoots": ["~/work/sandbox"] } +} +``` + +平台级配置**替换**(不是叠加)全局配置:上面这份配置里 WhatsApp 只能碰 `~/work/sandbox`,其余平台是 `~/work` 和 `~/side`。桌面端设置界面改的是全局那一份,某个平台单独配过之后,界面上的改动就不会影响它了。 + +独立运行 adapter(不走桌面端)时也可以用 `ADAPTER_ALLOWED_PROJECT_ROOTS` 环境变量,多个目录用路径分隔符隔开(macOS / Linux 是 `:`,Windows 是 `;`)。这个环境变量比配置文件里的两层都优先。 + +几条边界规则: + +- 目录必须是已存在的绝对路径(`~` 会展开)。写不存在的路径会被忽略并打日志;一个都解析不出来时回退到默认值,而不是把机器人锁死。 +- 默认值不会自动继承 `/` 或 `/Users` 这类目录。桌面端以 GUI 方式启动时 sidecar 的工作目录是 `/`,直接拿来当边界等于没有边界。 +- 「默认项目」如果落在允许范围之外,新会话会开在列表里第一个允许的目录,并打一条日志——这样 `/new` 不会因为两处配置不一致而失败。 + +配对仍然是第一道关:没配对的人根本发不了命令,这份目录列表是在此之上的第二道防线。注意主目录里也包含 `~/.claude`、`~/.ssh` 这些敏感目录,需要更强隔离就显式收紧到具体的项目目录。 + ## 通用命令 各平台的入口略有差异(飞书可以把命令配成机器人菜单),但这几条到处都能用: diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index 8d60d0f8..fa872686 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -214,6 +214,40 @@ describe('Adapters API', () => { await expect(fs.stat(path.join(tmpDir, 'adapters.json'))).rejects.toThrow() }) + // #1191: the IM path boundary is configured separately from the default project, + // globally and per platform. + it('persists allowed project roots globally and per platform', async () => { + const request = makeRequest('PUT', '/api/adapters', { + allowedProjectRoots: ['~/work', '/srv/projects'], + feishu: { allowedProjectRoots: ['~/work/only-this'] }, + }) + const response = await handleAdaptersApi(request.req, request.url, request.segments) + expect(response.status).toBe(200) + + const saved = JSON.parse(await fs.readFile(path.join(tmpDir, 'adapters.json'), 'utf-8')) + expect(saved.allowedProjectRoots).toEqual(['~/work', '/srv/projects']) + expect(saved.feishu.allowedProjectRoots).toEqual(['~/work/only-this']) + + // The settings UI reads these back from GET; masking must not drop them. + const read = makeRequest('GET', '/api/adapters') + const config = await (await handleAdaptersApi(read.req, read.url, read.segments)).json() as Record + expect(config.allowedProjectRoots).toEqual(['~/work', '/srv/projects']) + expect(config.feishu.allowedProjectRoots).toEqual(['~/work/only-this']) + }) + + it('rejects malformed allowed project roots', async () => { + for (const request of [ + makeRequest('PUT', '/api/adapters', { allowedProjectRoots: '/not-an-array' }), + makeRequest('PUT', '/api/adapters', { allowedProjectRoots: [''] }), + makeRequest('PUT', '/api/adapters', { allowedProjectRoots: [123] }), + makeRequest('PUT', '/api/adapters', { telegram: { allowedProjectRoots: [null] } }), + ]) { + const response = await handleAdaptersApi(request.req, request.url, request.segments) + expect(response.status).toBe(400) + } + await expect(fs.stat(path.join(tmpDir, 'adapters.json'))).rejects.toThrow() + }) + it('rejects malformed QR polling payloads before invoking platform protocols', async () => { for (const request of [ makeRawRequest('POST', '/api/adapters/wechat/login/poll', 'null'), diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts index 981aa065..5a85acea 100644 --- a/src/server/api/adapters.ts +++ b/src/server/api/adapters.ts @@ -23,7 +23,7 @@ import { } from '../../../adapters/whatsapp/protocol.js' import { loadConfig } from '../../../adapters/common/config.js' -const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp', 'pairing']) +const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'allowedProjectRoots', 'telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp', 'pairing']) const MAX_TEXT_LENGTH = 16_384 const MAX_PATH_LENGTH = 4_096 const MAX_LIST_LENGTH = 1_000 @@ -201,6 +201,9 @@ function parseAdapterConfigPatch(value: unknown): Partial { if ('defaultProjectDir' in body) { patch.defaultProjectDir = readString(body.defaultProjectDir, 'defaultProjectDir', MAX_PATH_LENGTH) } + if ('allowedProjectRoots' in body) { + patch.allowedProjectRoots = readStringList(body.allowedProjectRoots, 'allowedProjectRoots') + } if ('pairing' in body) { const source = requireRecord(body.pairing, 'pairing') @@ -225,12 +228,13 @@ function parseAdapterConfigPatch(value: unknown): Partial { if ('telegram' in body) { const source = requireRecord(body.telegram, 'telegram') - assertKnownKeys(source, ['botToken', 'allowedUsers', 'pairedUsers', 'defaultWorkDir'], 'telegram') + assertKnownKeys(source, ['botToken', 'allowedUsers', 'pairedUsers', 'defaultWorkDir', 'allowedProjectRoots'], 'telegram') const telegram: NonNullable = {} readOptionalStringField(source, telegram, 'botToken', 'telegram.botToken') if ('allowedUsers' in source) telegram.allowedUsers = readTelegramUsers(source.allowedUsers) if ('pairedUsers' in source) telegram.pairedUsers = readPairedUsers(source.pairedUsers, 'telegram.pairedUsers') readOptionalStringField(source, telegram, 'defaultWorkDir', 'telegram.defaultWorkDir', MAX_PATH_LENGTH) + readOptionalStringListField(source, telegram, 'allowedProjectRoots', 'telegram.allowedProjectRoots') patch.telegram = telegram } @@ -238,7 +242,7 @@ function parseAdapterConfigPatch(value: unknown): Partial { const source = requireRecord(body.feishu, 'feishu') assertKnownKeys( source, - ['appId', 'appSecret', 'encryptKey', 'verificationToken', 'allowedUsers', 'pairedUsers', 'defaultWorkDir', 'streamingCard'], + ['appId', 'appSecret', 'encryptKey', 'verificationToken', 'allowedUsers', 'pairedUsers', 'defaultWorkDir', 'streamingCard', 'allowedProjectRoots'], 'feishu', ) const feishu: NonNullable = {} @@ -248,6 +252,7 @@ function parseAdapterConfigPatch(value: unknown): Partial { readOptionalStringListField(source, feishu, 'allowedUsers', 'feishu.allowedUsers') if ('pairedUsers' in source) feishu.pairedUsers = readPairedUsers(source.pairedUsers, 'feishu.pairedUsers') readOptionalStringField(source, feishu, 'defaultWorkDir', 'feishu.defaultWorkDir', MAX_PATH_LENGTH) + readOptionalStringListField(source, feishu, 'allowedProjectRoots', 'feishu.allowedProjectRoots') if ('streamingCard' in source) { if (typeof source.streamingCard !== 'boolean') throw ApiError.badRequest('feishu.streamingCard must be a boolean') feishu.streamingCard = source.streamingCard @@ -257,11 +262,12 @@ function parseAdapterConfigPatch(value: unknown): Partial { if ('wechat' in body) { const source = requireRecord(body.wechat, 'wechat') - assertKnownKeys(source, ['allowedUsers', 'pairedUsers', 'defaultWorkDir'], 'wechat') + assertKnownKeys(source, ['allowedUsers', 'pairedUsers', 'defaultWorkDir', 'allowedProjectRoots'], 'wechat') const wechat: NonNullable = {} readOptionalStringListField(source, wechat, 'allowedUsers', 'wechat.allowedUsers') if ('pairedUsers' in source) wechat.pairedUsers = readPairedUsers(source.pairedUsers, 'wechat.pairedUsers') readOptionalStringField(source, wechat, 'defaultWorkDir', 'wechat.defaultWorkDir', MAX_PATH_LENGTH) + readOptionalStringListField(source, wechat, 'allowedProjectRoots', 'wechat.allowedProjectRoots') patch.wechat = wechat } @@ -269,7 +275,7 @@ function parseAdapterConfigPatch(value: unknown): Partial { const source = requireRecord(body.dingtalk, 'dingtalk') assertKnownKeys( source, - ['clientId', 'clientSecret', 'allowedUsers', 'pairedUsers', 'defaultWorkDir', 'endpoint', 'permissionCardTemplateId'], + ['clientId', 'clientSecret', 'allowedUsers', 'pairedUsers', 'defaultWorkDir', 'endpoint', 'permissionCardTemplateId', 'allowedProjectRoots'], 'dingtalk', ) const dingtalk: NonNullable = {} @@ -279,16 +285,18 @@ function parseAdapterConfigPatch(value: unknown): Partial { readOptionalStringListField(source, dingtalk, 'allowedUsers', 'dingtalk.allowedUsers') if ('pairedUsers' in source) dingtalk.pairedUsers = readPairedUsers(source.pairedUsers, 'dingtalk.pairedUsers') readOptionalStringField(source, dingtalk, 'defaultWorkDir', 'dingtalk.defaultWorkDir', MAX_PATH_LENGTH) + readOptionalStringListField(source, dingtalk, 'allowedProjectRoots', 'dingtalk.allowedProjectRoots') patch.dingtalk = dingtalk } if ('whatsapp' in body) { const source = requireRecord(body.whatsapp, 'whatsapp') - assertKnownKeys(source, ['allowedUsers', 'pairedUsers', 'defaultWorkDir'], 'whatsapp') + assertKnownKeys(source, ['allowedUsers', 'pairedUsers', 'defaultWorkDir', 'allowedProjectRoots'], 'whatsapp') const whatsapp: NonNullable = {} readOptionalStringListField(source, whatsapp, 'allowedUsers', 'whatsapp.allowedUsers') if ('pairedUsers' in source) whatsapp.pairedUsers = readPairedUsers(source.pairedUsers, 'whatsapp.pairedUsers') readOptionalStringField(source, whatsapp, 'defaultWorkDir', 'whatsapp.defaultWorkDir', MAX_PATH_LENGTH) + readOptionalStringListField(source, whatsapp, 'allowedProjectRoots', 'whatsapp.allowedProjectRoots') patch.whatsapp = whatsapp } diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 4db5c0f3..f8333e9c 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -26,12 +26,14 @@ export type PairingState = { export type AdapterFileConfig = { serverUrl?: string defaultProjectDir?: string + allowedProjectRoots?: string[] pairing?: PairingState telegram?: { botToken?: string allowedUsers?: number[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } feishu?: { appId?: string @@ -41,6 +43,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] streamingCard?: boolean } wechat?: { @@ -51,6 +54,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } dingtalk?: { clientId?: string @@ -58,6 +62,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] endpoint?: string permissionCardTemplateId?: string } @@ -67,6 +72,7 @@ export type AdapterFileConfig = { allowedUsers?: string[] pairedUsers?: PairedUser[] defaultWorkDir?: string + allowedProjectRoots?: string[] } }