diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index e334d19c7ad..457f0ab432c 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -73,7 +73,7 @@ When both legacy settings are present with different values, the migration follo ### Available settings in `settings.json` -Settings are organized into categories. Most settings should be placed within their corresponding top-level category object in your `settings.json` file. A few top-level settings like `proxy` and `plansDirectory` remain direct root keys for compatibility. +Settings are organized into categories. Most settings should be placed within their corresponding top-level category object in your `settings.json` file. A few top-level settings like `proxy`, `plansDirectory`, and `todosDirectory` remain direct root keys for compatibility. #### general @@ -489,6 +489,7 @@ LSP server configuration is done through `.lsp.json` files in your project root | `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` | | `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` | | `plansDirectory` | string | Custom directory for approved Plan Mode files. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, plan files are stored in `~/.qwen/plans`. **Requires restart.** If the directory is inside the project root, add it to `.gitignore` to avoid committing plan files. | `undefined` | +| `todosDirectory` | string | Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, todo files are stored in the global runtime todos directory (`~/.qwen/todos`). **Requires restart.** | `undefined` | #### experimental @@ -545,6 +546,7 @@ Here is an example of a `settings.json` file with the nested structure, new as o { "proxy": "http://localhost:7890", "plansDirectory": "./.qwen/plans", + "todosDirectory": "./.qwen/todos", "general": { "vimMode": true, "preferredEditor": "code" diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 42896814869..8d26179bd12 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1979,6 +1979,7 @@ export async function loadCliConfig( clearContextOnIdle: settings.context?.clearContextOnIdle, fileFiltering: settings.context?.fileFiltering, plansDirectory: settings.plansDirectory, + todosDirectory: settings.todosDirectory, proxy: argv.proxy || settings.proxy || diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 04e6dd56d01..7c728d72fe7 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -34,6 +34,7 @@ describe('SettingsSchema', () => { 'security', 'advanced', 'plansDirectory', + 'todosDirectory', 'voiceModel', ]; @@ -168,6 +169,15 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().plansDirectory.showInDialog).toBe(false); }); + it('should have todosDirectory setting in schema', () => { + expect(getSettingsSchema().todosDirectory).toBeDefined(); + expect(getSettingsSchema().todosDirectory.type).toBe('string'); + expect(getSettingsSchema().todosDirectory.category).toBe('Advanced'); + expect(getSettingsSchema().todosDirectory.default).toBe(undefined); + expect(getSettingsSchema().todosDirectory.requiresRestart).toBe(true); + expect(getSettingsSchema().todosDirectory.showInDialog).toBe(false); + }); + it('should have voice model setting in schema', () => { const voiceModel = getSettingsSchema().voiceModel; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index cac410f8ad3..bb8b0eefb1e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -339,6 +339,17 @@ const SETTINGS_SCHEMA = { showInDialog: false, }, + todosDirectory: { + type: 'string', + label: 'Todos Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. Defaults to the global runtime todos directory (~/.qwen/todos).', + showInDialog: false, + }, + // Environment variables fallback env: { type: 'object', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 630802d43e0..8331bbf8e5d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -917,6 +917,7 @@ export interface ConfigParameters { fileCheckpointingEnabled?: boolean; /** Directory where approved plan files are stored. Must resolve inside targetDir. */ plansDirectory?: string; + todosDirectory?: string; proxy?: string; cwd: string; fileDiscoveryService?: FileDiscoveryService; @@ -1544,6 +1545,7 @@ export class Config { private readonly jsonSchema: Record | undefined; private readonly inputFile: string | undefined; private readonly plansDir: string; + private readonly todosDir: string; private readonly plansDirectoryConfigured: boolean; private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly enableManagedAutoMemory: boolean; @@ -1594,6 +1596,7 @@ export class Config { this.targetDir = path.resolve(params.targetDir); this.plansDirectoryConfigured = Boolean(params.plansDirectory?.trim()); this.plansDir = Storage.getPlansDir(this.targetDir, params.plansDirectory); + this.todosDir = Storage.getTodosDir(this.targetDir, params.todosDirectory); this.explicitIncludeDirectories = Array.from( new Set(params.includeDirectories ?? []), ); @@ -4611,6 +4614,10 @@ export class Config { return this.plansDir; } + getTodosDir(): string { + return this.todosDir; + } + private assertPlansDirWithinTargetDir(): void { if (!this.plansDirectoryConfigured) { return; diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 4d7a7bfaf54..d7ba0a30308 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { Storage } from './storage.js'; +import { FatalConfigError } from '../utils/errors.js'; const mockRealpathSync = vi.hoisted(() => vi.fn()); @@ -346,6 +347,65 @@ describe('Storage – getPlansDir', () => { }); }); +describe('Storage – getTodosDir', () => { + const projectRoot = path.resolve('workspace', 'project'); + const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + mockRealpathSync.mockImplementation((pathToResolve) => + actualFs.realpathSync(pathToResolve), + ); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalRuntimeEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + mockRealpathSync.mockReset(); + }); + + it('defaults to the runtime base todos directory when todosDirectory is not configured', () => { + const runtimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(runtimeDir); + + expect(Storage.getTodosDir(projectRoot)).toBe( + path.join(Storage.getRuntimeBaseDir(), 'todos'), + ); + }); + + it('resolves relative todosDirectory values against the project root', () => { + expect(Storage.getTodosDir(projectRoot, './.qwen/todos')).toBe( + path.join(projectRoot, '.qwen', 'todos'), + ); + }); + + it('rejects todosDirectory values that escape the project root', () => { + expect(() => Storage.getTodosDir(projectRoot, '../todos')).toThrow( + FatalConfigError, + ); + expect(() => Storage.getTodosDir(projectRoot, '../todos')).toThrow( + 'todosDirectory must resolve within the project root', + ); + }); + + it('requires projectRoot when todosDirectory is configured', () => { + expect(() => Storage.getTodosDir(undefined, './todos')).toThrow( + FatalConfigError, + ); + expect(() => Storage.getTodosDir(undefined, './todos')).toThrow( + 'projectRoot is required when todosDirectory is configured', + ); + expect(() => Storage.getTodosDir(null, './todos')).toThrow( + 'projectRoot is required when todosDirectory is configured', + ); + }); +}); + describe('Storage – runtime path methods use getRuntimeBaseDir', () => { const originalEnv = process.env['QWEN_RUNTIME_DIR']; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 98d21386ad7..4a0b678e9b6 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -20,6 +20,7 @@ const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; const IDE_DIR_NAME = 'ide'; const PLANS_DIR_NAME = 'plans'; +const TODOS_DIR_NAME = 'todos'; const DEBUG_DIR_NAME = 'debug'; const ARENA_DIR_NAME = 'arena'; @@ -298,6 +299,36 @@ export class Storage { ); } + static getTodosDir( + projectRoot?: string | null, + todosDirectory?: string | null, + ): string { + const configuredTodosDirectory = todosDirectory?.trim(); + if (configuredTodosDirectory) { + if (!projectRoot) { + throw new FatalConfigError( + 'projectRoot is required when todosDirectory is configured.', + ); + } + + const resolvedProjectRoot = path.resolve(projectRoot); + const resolvedTodosDirectory = Storage.resolvePath( + configuredTodosDirectory, + resolvedProjectRoot, + ); + + Storage.assertPathWithinDirectory( + resolvedTodosDirectory, + resolvedProjectRoot, + `todosDirectory must resolve within the project root.`, + ); + + return resolvedTodosDirectory; + } + + return path.join(Storage.getRuntimeBaseDir(), TODOS_DIR_NAME); + } + static getGlobalBinDir(): string { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } diff --git a/packages/core/src/tools/todoWrite.test.ts b/packages/core/src/tools/todoWrite.test.ts index 5f6cecbb099..fe950be5623 100644 --- a/packages/core/src/tools/todoWrite.test.ts +++ b/packages/core/src/tools/todoWrite.test.ts @@ -6,19 +6,41 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { TodoWriteParams } from './todoWrite.js'; -import { TodoWriteTool, listTodoSessions } from './todoWrite.js'; +import { + TodoWriteTool, + listTodoSessions, + readTodosForSession, +} from './todoWrite.js'; import { DefaultHookOutput, HookPhase, type TodoItem } from '../hooks/types.js'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; +import type { PathLike } from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import type { Config } from '../config/config.js'; import type { AggregatedHookResult } from '../hooks/hookAggregator.js'; import { Storage } from '../config/storage.js'; import { atomicWriteFile } from '../utils/atomicFileWrite.js'; -// Mock fs modules +const mockRealpathSync = vi.hoisted(() => vi.fn()); + +// Mock fs modules. `fs` and `node:fs` resolve to the same module, so a single +// factory covers both. It keeps existsSync as a mock fn (the auto-mock behavior +// the getDescription tests rely on) and overrides realpathSync with the hoisted +// mock used by the containment/symlink tests. vi.mock('fs/promises'); -vi.mock('fs'); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + const mocked = { + ...actual, + existsSync: vi.fn(), + realpathSync: mockRealpathSync, + }; + return { + ...mocked, + default: mocked, + }; +}); vi.mock('../utils/atomicFileWrite.js', () => ({ atomicWriteFile: vi.fn(), @@ -28,6 +50,17 @@ const mockFs = vi.mocked(fs); const mockFsSync = vi.mocked(fsSync); const mockAtomicWrite = vi.mocked(atomicWriteFile); +function mockRealpath(realpaths = new Map()) { + mockRealpathSync.mockImplementation((pathToResolve: PathLike) => { + const resolvedPath = pathToResolve.toString(); + return realpaths.get(resolvedPath) ?? resolvedPath; + }); +} + +beforeEach(() => { + mockRealpath(); +}); + describe('TodoWriteTool', () => { let tool: TodoWriteTool; let mockAbortSignal: AbortSignal; @@ -37,6 +70,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => undefined, + getTodosDir: () => Storage.getTodosDir(), } as Config; tool = new TodoWriteTool(mockConfig); mockAbortSignal = new AbortController().signal; @@ -174,6 +208,35 @@ describe('TodoWriteTool', () => { ); }); + it('should sanitize session ID when building todo file path', async () => { + mockConfig = { + getSessionId: () => '../../../escape', + getHookSystem: () => undefined, + getTodosDir: () => Storage.getTodosDir(), + } as Config; + tool = new TodoWriteTool(mockConfig); + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + const todoFilePath = path.join(Storage.getTodosDir(), 'escape.json'); + + const enoentError = new Error('ENOENT') as Error & { code: string }; + enoentError.code = 'ENOENT'; + mockFs.readFile.mockRejectedValue(enoentError); + mockFs.mkdir.mockResolvedValue(undefined); + mockAtomicWrite.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + expect(mockFs.readFile).toHaveBeenCalledWith(todoFilePath, 'utf-8'); + expect(mockAtomicWrite).toHaveBeenCalledWith( + todoFilePath, + expect.stringContaining('"todos"'), + { encoding: 'utf-8' }, + ); + }); + it('should replace todos with new ones', async () => { const existingTodos = [ { id: '1', content: 'Existing Task', status: 'completed' }, @@ -296,6 +359,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -350,6 +414,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -416,6 +481,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -493,6 +559,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -543,6 +610,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -610,6 +678,7 @@ describe('TodoWriteTool', () => { mockConfig = { getSessionId: () => 'test-session-123', getHookSystem: () => mockHookSystem, + getTodosDir: () => Storage.getTodosDir(), } as unknown as Config; tool = new TodoWriteTool(mockConfig); @@ -699,6 +768,7 @@ describe('TodoWriteTool – runtime output directory', () => { mockConfig = { getSessionId: () => 'runtime-session', getHookSystem: () => undefined, + getTodosDir: () => Storage.getTodosDir(), } as Config; tool = new TodoWriteTool(mockConfig); mockAbortSignal = new AbortController().signal; @@ -782,6 +852,138 @@ describe('TodoWriteTool – runtime output directory', () => { expect(writePath).toContain(path.join('.qwen', 'todos')); }); + it('should read and write todos from the configured todos directory', async () => { + const configuredTodoDir = path.join(os.tmpdir(), 'qwen-configured-todos'); + mockConfig = { + getSessionId: () => 'configured-session', + getHookSystem: () => undefined, + getTodosDir: () => configuredTodoDir, + } as Config; + tool = new TodoWriteTool(mockConfig); + + const existingTodos: TodoItem[] = [ + { id: '1', content: 'Existing task', status: 'pending' }, + ]; + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Existing task', status: 'completed' }], + }; + const todoFilePath = path.join( + configuredTodoDir, + 'configured-session.json', + ); + + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + todos: existingTodos, + sessionId: 'configured-session', + }), + ); + mockFs.mkdir.mockResolvedValue(undefined); + mockAtomicWrite.mockResolvedValue(undefined); + + const invocation = tool.build(params); + const result = await invocation.execute(mockAbortSignal); + + expect(mockFs.readFile).toHaveBeenCalledWith(todoFilePath, 'utf-8'); + expect(mockFs.mkdir).toHaveBeenCalledWith(configuredTodoDir, { + recursive: true, + }); + expect(mockAtomicWrite).toHaveBeenCalledWith( + todoFilePath, + expect.any(String), + { encoding: 'utf-8' }, + ); + const persisted = JSON.parse(mockAtomicWrite.mock.calls[0]?.[1] as string); + expect(persisted).toEqual({ + todos: params.todos, + sessionId: 'configured-session', + }); + expect(result.llmContent).toContain( + 'Todos have been modified successfully', + ); + }); + + it('should read todos for a session from a configured todos directory', async () => { + const configuredTodoDir = path.join(os.tmpdir(), 'qwen-configured-todos'); + const todos: TodoItem[] = [ + { id: '1', content: 'Existing task', status: 'pending' }, + ]; + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + todos, + sessionId: 'configured-session', + }), + ); + + const result = await readTodosForSession( + 'configured-session', + configuredTodoDir, + ); + + expect(mockFs.readFile).toHaveBeenCalledWith( + path.join(configuredTodoDir, 'configured-session.json'), + 'utf-8', + ); + expect(result).toEqual(todos); + }); + + it('should reject a configured todosDirectory that escapes targetDir before writing', async () => { + const projectRoot = path.resolve('workspace', 'project'); + const outsideTodoDir = path.resolve(projectRoot, '..', 'outside-todos'); + mockConfig = { + getSessionId: () => 'configured-session', + getHookSystem: () => undefined, + getTodosDir: () => outsideTodoDir, + getTargetDir: () => projectRoot, + } as Config; + tool = new TodoWriteTool(mockConfig); + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + const invocation = tool.build(params); + const result = await invocation.execute(mockAbortSignal); + + expect(result.returnDisplay).toContain( + 'todosDirectory must resolve within the project root', + ); + expect(mockFs.readFile).not.toHaveBeenCalled(); + expect(mockFs.mkdir).not.toHaveBeenCalled(); + expect(mockAtomicWrite).not.toHaveBeenCalled(); + }); + + it('should reject a configured todosDirectory symlink that escapes targetDir before writing', async () => { + const projectRoot = path.resolve('workspace', 'project'); + const todoDir = path.join(projectRoot, '.qwen', 'todos'); + const outsideTodoDir = path.resolve(projectRoot, '..', 'outside-todos'); + mockRealpath( + new Map([ + [projectRoot, projectRoot], + [todoDir, outsideTodoDir], + ]), + ); + mockConfig = { + getSessionId: () => 'configured-session', + getHookSystem: () => undefined, + getTodosDir: () => todoDir, + getTargetDir: () => projectRoot, + } as Config; + tool = new TodoWriteTool(mockConfig); + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + const invocation = tool.build(params); + const result = await invocation.execute(mockAbortSignal); + + expect(result.returnDisplay).toContain( + 'todosDirectory must resolve within the project root', + ); + expect(mockFs.readFile).not.toHaveBeenCalled(); + expect(mockFs.mkdir).not.toHaveBeenCalled(); + expect(mockAtomicWrite).not.toHaveBeenCalled(); + }); + it('should check file existence in custom runtime dir for getDescription', () => { const customRuntimeDir = path.resolve('custom', 'runtime'); Storage.setRuntimeBaseDir(customRuntimeDir); @@ -814,4 +1016,18 @@ describe('TodoWriteTool – runtime output directory', () => { ); expect(sessions).toEqual(['a', 'b']); }); + + it('should list todo sessions from a configured todos directory', async () => { + const configuredTodoDir = path.join(os.tmpdir(), 'qwen-configured-todos'); + mockFs.readdir.mockResolvedValue([ + 'configured.json', + 'other.json', + 'README.md', + ] as never); + + const sessions = await listTodoSessions(configuredTodoDir); + + expect(mockFs.readdir).toHaveBeenCalledWith(configuredTodoDir); + expect(sessions).toEqual(['configured', 'other']); + }); }); diff --git a/packages/core/src/tools/todoWrite.ts b/packages/core/src/tools/todoWrite.ts index 488931fe0f0..d8c363b0d29 100644 --- a/packages/core/src/tools/todoWrite.ts +++ b/packages/core/src/tools/todoWrite.ts @@ -246,22 +246,55 @@ When new information changes your understanding of the task, update the todo str When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. `; -const TODO_SUBDIR = 'todos'; +function getTodoFilePath(todoDir: string, sessionId?: string): string { + const filename = `${Storage.sanitizePlanSessionId( + sessionId || 'default', + )}.json`; + return path.join(todoDir, filename); +} -function getTodoFilePath(sessionId?: string): string { - const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); +function isConfiguredTodoDir(todoDir: string): boolean { + return path.resolve(todoDir) !== path.resolve(Storage.getTodosDir()); +} - // Use sessionId if provided, otherwise fall back to 'default' - const filename = `${sessionId || 'default'}.json`; - return path.join(todoDir, filename); +function assertTodoPathWithinAllowedDirectory( + todoDir: string, + todoFilePath: string, + projectRoot?: string, +): void { + Storage.assertPathWithinDirectory( + todoFilePath, + todoDir, + `todosDirectory must resolve within the project root.`, + ); + + if (!projectRoot || !isConfiguredTodoDir(todoDir)) { + return; + } + + Storage.assertPathWithinDirectory( + todoDir, + projectRoot, + `todosDirectory must resolve within the project root.`, + ); + Storage.assertPathWithinDirectory( + todoFilePath, + projectRoot, + `todosDirectory must resolve within the project root.`, + ); } /** * Reads the current todos from the file system */ -async function readTodosFromFile(sessionId?: string): Promise { +async function readTodosFromFile( + todoDir: string, + sessionId?: string, + projectRoot?: string, +): Promise { try { - const todoFilePath = getTodoFilePath(sessionId); + const todoFilePath = getTodoFilePath(todoDir, sessionId); + assertTodoPathWithinAllowedDirectory(todoDir, todoFilePath, projectRoot); const content = await fs.readFile(todoFilePath, 'utf-8'); const data = JSON.parse(content); return Array.isArray(data.todos) ? data.todos : []; @@ -278,20 +311,25 @@ async function readTodosFromFile(sessionId?: string): Promise { * Writes todos to the file system */ async function writeTodosToFile( + todoDir: string, todos: TodoItem[], sessionId?: string, + projectRoot?: string, ): Promise { - const todoFilePath = getTodoFilePath(sessionId); - const todoDir = path.dirname(todoFilePath); + const todoFilePath = getTodoFilePath(todoDir, sessionId); + const todoFileDir = path.dirname(todoFilePath); - await fs.mkdir(todoDir, { recursive: true }); + assertTodoPathWithinAllowedDirectory(todoDir, todoFilePath, projectRoot); + await fs.mkdir(todoFileDir, { recursive: true }); const data = { todos, sessionId: sessionId || 'default', }; - await atomicWriteFile(todoFilePath, JSON.stringify(data, null, 2), { + const contents = JSON.stringify(data, null, 2); + assertTodoPathWithinAllowedDirectory(todoDir, todoFilePath, projectRoot); + await atomicWriteFile(todoFilePath, contents, { encoding: 'utf-8', }); } @@ -332,10 +370,12 @@ class TodoWriteToolInvocation extends BaseToolInvocation< async execute(_signal: AbortSignal): Promise { const { todos, modified_by_user, modified_content } = this.params; const sessionId = this.config.getSessionId(); + const todoDir = this.config.getTodosDir(); + const projectRoot = this.config.getTargetDir?.(); try { // 1. Read current todos (for change detection) - const oldTodos = await readTodosFromFile(sessionId); + const oldTodos = await readTodosFromFile(todoDir, sessionId, projectRoot); let finalTodos: TodoItem[]; @@ -418,7 +458,7 @@ class TodoWriteToolInvocation extends BaseToolInvocation< } // 4. Write new todos AFTER all validation passes - await writeTodosToFile(finalTodos, sessionId); + await writeTodosToFile(todoDir, finalTodos, sessionId, projectRoot); // 5. POST-WRITE PHASE: Execute hooks for side effects (logging, HTTP sync, etc.) // These hooks can now safely perform side effects knowing data is persisted @@ -532,17 +572,18 @@ Todo list modification failed with error: ${errorMessage}. You may need to retry */ export async function readTodosForSession( sessionId?: string, + todoDir?: string, ): Promise { - return readTodosFromFile(sessionId); + return readTodosFromFile(todoDir ?? Storage.getTodosDir(), sessionId); } /** * Utility function to list all todo files in the todos directory */ -export async function listTodoSessions(): Promise { +export async function listTodoSessions(todoDir?: string): Promise { try { - const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); - const files = await fs.readdir(todoDir); + const resolvedTodoDir = todoDir ?? Storage.getTodosDir(); + const files = await fs.readdir(resolvedTodoDir); return files .filter((file: string) => file.endsWith('.json')) .map((file: string) => file.replace('.json', '')); @@ -607,7 +648,7 @@ export class TodoWriteTool extends BaseDeclarativeTool< protected createInvocation(params: TodoWriteParams) { // Determine if this is a create or update operation by checking if todos file exists const sessionId = this.config.getSessionId(); - const todoFilePath = getTodoFilePath(sessionId); + const todoFilePath = getTodoFilePath(this.config.getTodosDir(), sessionId); const operationType = fsSync.existsSync(todoFilePath) ? 'update' : 'create'; return new TodoWriteToolInvocation(this.config, params, operationType); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 35f0039f900..392b2f1ca65 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -27,6 +27,10 @@ "description": "Custom directory for approved Plan Mode files. Relative paths are resolved from the project root, and the resolved path must stay within the project root. Defaults to ~/.qwen/plans.", "type": "string" }, + "todosDirectory": { + "description": "Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. Defaults to the global runtime todos directory (~/.qwen/todos).", + "type": "string" + }, "env": { "description": "Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.", "type": "object",