From 94168b3b577e839137a3314932840a8b66514f6e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?=
=?UTF-8?q?=29?=
Date: Wed, 5 Aug 2026 23:38:30 +0800
Subject: [PATCH] fix(adapters): restore IM /projects beyond the default
project (#1191)
Since v0.5.1 every IM channel listed only the default project. All five
adapters passed the default work dir to AdapterHttpClient as the sole
allowed project root, so listRecentProjects filtered out everything else;
matchProject, listSessions, sessionExists, createSession and listSkills
were clamped the same way. Feishu is where it was reported, but telegram,
wechat, dingtalk and whatsapp were identical.
defaultWorkDir is documented as where a new IM session starts, not as an
access boundary. Using it as the boundary failed both ways: configured, it
hid every other project; blank, it falls back to PWD/cwd(), which is "/"
for a GUI-launched sidecar, so the boundary allowed the whole filesystem.
Split the two concepts. allowedProjectRoots is now its own setting (global,
per-platform, or ADAPTER_ALLOWED_PROJECT_ROOTS), resolved together with the
work dir by resolveAdapterWorkspace so the default project is always inside
the boundary and /new cannot fail on inconsistent config. The default is the
home directory; it refuses to inherit "/" or any ancestor of home. Pairing
remains the primary authorization control, so unusable roots warn and fall
back rather than locking the bot out.
All five entrypoints now build their client through createAdapterClient
instead of repeating the wiring, which is what let one defect appear in five
places at once.
Known gap, left for a follow-up: a project outside the boundary is still
reported as "not found" rather than "outside the allowed directories".
---
.../common/__tests__/adapter-client.test.ts | 169 ++++++++++++++++
adapters/common/__tests__/config.test.ts | 185 +++++++++++++++++-
adapters/common/__tests__/http-client.test.ts | 44 +++++
adapters/common/adapter-client.ts | 34 ++++
adapters/common/config.ts | 170 +++++++++++++++-
adapters/dingtalk/index.ts | 8 +-
adapters/feishu/index.ts | 8 +-
adapters/telegram/index.ts | 7 +-
adapters/wechat/index.ts | 7 +-
adapters/whatsapp/index.ts | 7 +-
desktop/src/i18n/locales/en.ts | 5 +
desktop/src/i18n/locales/jp.ts | 5 +
desktop/src/i18n/locales/kr.ts | 5 +
desktop/src/i18n/locales/zh-TW.ts | 5 +
desktop/src/i18n/locales/zh.ts | 5 +
desktop/src/pages/AdapterSettings.test.tsx | 70 +++++++
desktop/src/pages/AdapterSettings.tsx | 65 ++++++
desktop/src/types/adapter.ts | 6 +
docs/desktop/remote.md | 3 +-
docs/im/index.md | 27 +++
src/server/__tests__/adapters.test.ts | 34 ++++
src/server/api/adapters.ts | 20 +-
src/server/services/adapterService.ts | 6 +
23 files changed, 864 insertions(+), 31 deletions(-)
create mode 100644 adapters/common/__tests__/adapter-client.test.ts
create mode 100644 adapters/common/adapter-client.ts
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)。留空 = 主目录,足以覆盖绝大多数用法。 */}
+
+
+ {t('settings.adapters.allowedRoots')}
+
+ {allowedProjectRoots.length > 0 ? (
+
+ {allowedProjectRoots.map((root) => (
+
+ {root}
+ setAllowedProjectRoots((prev) => prev.filter((item) => item !== root))}
+ >
+ {t('settings.adapters.removeAllowedRoot')}
+
+
+ ))}
+
+ ) : (
+
+ {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[]
}
}