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[]
}
}