diff --git a/package-lock.json b/package-lock.json index 4ddeda80f13..04393e5b729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13121,6 +13121,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -17222,7 +17223,6 @@ "packages/acp-bridge": { "name": "@qwen-code/acp-bridge", "version": "0.0.1", - "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@qwen-code/qwen-code-core": "file:../core" diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 7d78ffd48f9..a01cf80ac9d 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -307,15 +307,17 @@ export interface ServeSessionSupportedCommandsStatus { * so the SDK reducer can render any of these with one pattern. */ -export type ServeContextFileScope = 'workspace' | 'global'; +export type ServeContextFileScope = 'workspace' | 'local' | 'global'; export interface ServeWorkspaceMemoryFile { kind: 'memory_file'; /** Absolute path to the discovered memory file. */ path: string; /** - * 'workspace' for files under the bound workspace tree, 'global' for - * `~/.qwen/QWEN.md` style entries. Helps adapters render scope chips. + * 'workspace' for files under the bound workspace tree, 'local' for + * `.qwen/QWEN.local.md` (gitignored project-local instructions), + * 'global' for `~/.qwen/QWEN.md` style entries. + * Helps adapters render scope chips. */ scope: ServeContextFileScope; /** Size in bytes of the file's serialized contents on disk. */ diff --git a/packages/cli/src/serve/workspaceMemory.ts b/packages/cli/src/serve/workspaceMemory.ts index e7a0e7ef2c6..8149d578230 100644 --- a/packages/cli/src/serve/workspaceMemory.ts +++ b/packages/cli/src/serve/workspaceMemory.ts @@ -12,8 +12,11 @@ import { WorkspaceMemoryFileTooLargeError, WorkspaceMemoryWriteTimeoutError, getAllGeminiMdFilenames, + getLocalContextFilePath, writeWorkspaceContextFile, + type WriteContextFileScope, } from '@qwen-code/qwen-code-core'; +import { findProjectRoot } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { isServeDebugMode } from './debugMode.js'; import type { HttpAcpBridge } from './httpAcpBridge.js'; @@ -78,6 +81,13 @@ export interface WorkspaceMemoryRouteDeps { const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024; +const VALID_SCOPES: ReadonlySet = new Set([ + 'workspace', + 'global', + 'local', + 'auto', +]); + /** Mount the two memory routes on the supplied Express app. */ export function mountWorkspaceMemoryRoutes( app: Application, @@ -116,9 +126,9 @@ export function mountWorkspaceMemoryRoutes( const body = deps.safeBody(req); const scope = body['scope']; - if (scope !== 'workspace' && scope !== 'global') { + if (!VALID_SCOPES.has(scope as string)) { res.status(400).json({ - error: '`scope` must be "workspace" or "global"', + error: `\`scope\` must be one of: ${[...VALID_SCOPES].map((s) => `"${s}"`).join(', ')}`, code: 'invalid_scope', }); return; @@ -183,7 +193,7 @@ export function mountWorkspaceMemoryRoutes( try { const result = await writeWorkspaceContextFile({ - scope, + scope: scope as WriteContextFileScope, mode, content, projectRoot: deps.boundWorkspace, @@ -204,7 +214,7 @@ export function mountWorkspaceMemoryRoutes( deps.bridge.publishWorkspaceEvent({ type: 'memory_changed', data: { - scope, + scope: result.resolvedScope, filePath: result.filePath, mode, bytesWritten: result.bytesWritten, @@ -337,6 +347,36 @@ async function collectWorkspaceMemoryStatus( ); files.push(...workspaceFiles); + // Probe for .qwen/QWEN.local.md at the project root (git root, not + // necessarily the bound workspace). Uses findProjectRoot to match + // the discovery path in memoryDiscovery.ts — in a monorepo where + // the daemon is bound to a subdirectory, both must probe the same + // directory or writes will silently disappear from the prompt. + const resolvedWorkspace = path.resolve(boundWorkspace); + const foundProjectRoot = await findProjectRoot(resolvedWorkspace); + const effectiveRoot = foundProjectRoot ?? resolvedWorkspace; + const localPath = getLocalContextFilePath(effectiveRoot); + try { + const stat = await fs.stat(localPath); + if (stat.isFile()) { + files.push({ + absolutePath: localPath, + scope: 'local', + bytes: stat.size, + }); + } + } catch (err) { + if (!isEnoent(err)) { + errors.push({ + kind: 'memory_file', + status: 'error', + error: err instanceof Error ? err.message : String(err), + errorKind: 'stat_failed', + hint: localPath, + }); + } + } + const globalDir = Storage.getGlobalQwenDir(); for (const filename of filenames) { const candidate = path.join(globalDir, filename); diff --git a/packages/core/src/memory/const.ts b/packages/core/src/memory/const.ts index 7b23ebaf74d..45644522c6e 100644 --- a/packages/core/src/memory/const.ts +++ b/packages/core/src/memory/const.ts @@ -4,10 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; +import { QWEN_DIR } from '../utils/paths.js'; + export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; export const AGENT_CONTEXT_FILENAME = 'AGENTS.md'; +export const LOCAL_CONTEXT_FILENAME = 'QWEN.local.md'; export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; +/** + * Returns the absolute path to `.qwen/QWEN.local.md` for a given + * project root. Centralized here so all call sites use a single + * source of truth — if the directory name or filename changes, only + * this function needs updating. + */ +export function getLocalContextFilePath(projectRoot: string): string { + return path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME); +} + // This variable will hold the currently configured filename for context files. // It defaults to include both QWEN.md and AGENTS.md but can be overridden by setGeminiMdFilename. // QWEN.md is first to maintain backward compatibility (used by /init command tool). diff --git a/packages/core/src/memory/writeContextFile.test.ts b/packages/core/src/memory/writeContextFile.test.ts index 3664dd8cc15..2afb8ee88cf 100644 --- a/packages/core/src/memory/writeContextFile.test.ts +++ b/packages/core/src/memory/writeContextFile.test.ts @@ -5,13 +5,17 @@ */ import { promises as fs } from 'node:fs'; +import * as fsSync from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Storage } from '../config/storage.js'; +import { QWEN_DIR } from '../utils/paths.js'; import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, + getLocalContextFilePath, + LOCAL_CONTEXT_FILENAME, MEMORY_SECTION_HEADER, setGeminiMdFilename, } from './const.js'; @@ -391,4 +395,611 @@ describe('writeWorkspaceContextFile', () => { setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); } }); + + describe('auto scope', () => { + let savedFilenames: string | string[]; + + beforeEach(() => { + // Capture whatever the filename list is before we touch it. + savedFilenames = [DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]; + setGeminiMdFilename(savedFilenames); + }); + + afterEach(() => { + setGeminiMdFilename(savedFilenames); + }); + + it('writes to project QWEN.md when one exists at the project root', async () => { + const projectQwenPath = path.join(workspace, DEFAULT_CONTEXT_FILENAME); + await fs.writeFile(projectQwenPath, '# project\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- memory entry', + projectRoot: workspace, + }); + + expect(result.filePath).toBe(projectQwenPath); + const written = await fs.readFile(projectQwenPath, 'utf8'); + expect(written).toContain('- memory entry'); + // Global dir must not have been touched. + const globalPath = path.join(globalDir, DEFAULT_CONTEXT_FILENAME); + await expect(fs.access(globalPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('writes to project AGENTS.md when that exists instead of QWEN.md', async () => { + const agentsPath = path.join(workspace, AGENT_CONTEXT_FILENAME); + await fs.writeFile(agentsPath, '# agents\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- agents memory', + projectRoot: workspace, + }); + + expect(result.filePath).toBe(agentsPath); + const written = await fs.readFile(agentsPath, 'utf8'); + expect(written).toContain('- agents memory'); + // QWEN.md must not be created. + await expect( + fs.access(path.join(workspace, DEFAULT_CONTEXT_FILENAME)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('refuses to fall back to global when no project context file exists', async () => { + await expect( + writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- fallback entry', + projectRoot: workspace, + }), + ).rejects.toThrow( + "scope='auto' is not allowed when auto resolves to the global directory", + ); + expect(getGlobalQwenDirSpy).toHaveBeenCalled(); + }); + + it('prefers QWEN.md over AGENTS.md when both exist (QWEN.md first in filenames list)', async () => { + const qwenPath = path.join(workspace, DEFAULT_CONTEXT_FILENAME); + const agentsPath = path.join(workspace, AGENT_CONTEXT_FILENAME); + await fs.writeFile(qwenPath, '# qwen\n', 'utf8'); + await fs.writeFile(agentsPath, '# agents\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- which one?', + projectRoot: workspace, + }); + + // Should prefer QWEN.md since getAllGeminiMdFilenames returns it first. + expect(result.filePath).toBe(qwenPath); + const qwenContent = await fs.readFile(qwenPath, 'utf8'); + expect(qwenContent).toContain('- which one?'); + }); + + it('falls back to local file when no committed context exists', async () => { + const localPath = path.join(workspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, '# local\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- auto local entry', + projectRoot: workspace, + }); + + expect(result.filePath).toBe(localPath); + const written = await fs.readFile(localPath, 'utf8'); + expect(written).toContain('- auto local entry'); + + // The path-based guard must also gitignore the auto-resolved local + // file, mirroring explicit scope='local' behavior. + const gitignore = await fs.readFile( + path.join(workspace, '.gitignore'), + 'utf8', + ); + expect(gitignore).toContain(`${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`); + }); + + it('prefers committed QWEN.md over local file in auto scope', async () => { + const qwenPath = path.join(workspace, DEFAULT_CONTEXT_FILENAME); + const localPath = path.join(workspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME); + await fs.writeFile(qwenPath, '# qwen\n', 'utf8'); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, '# local\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- which scope?', + projectRoot: workspace, + }); + + expect(result.filePath).toBe(qwenPath); + }); + }); + + describe('local scope', () => { + it('writes to .qwen/QWEN.local.md', async () => { + const result = await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- local entry', + projectRoot: workspace, + }); + + const expectedPath = path.join( + workspace, + QWEN_DIR, + LOCAL_CONTEXT_FILENAME, + ); + expect(result.filePath).toBe(expectedPath); + const written = await fs.readFile(expectedPath, 'utf8'); + expect(written).toBe(`${MEMORY_SECTION_HEADER}\n- local entry\n`); + }); + + it('creates the .qwen directory if missing', async () => { + const qwenDir = path.join(workspace, QWEN_DIR); + await expect(fs.access(qwenDir)).rejects.toMatchObject({ + code: 'ENOENT', + }); + + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- created', + projectRoot: workspace, + }); + + const stat = await fs.stat(qwenDir); + expect(stat.isDirectory()).toBe(true); + }); + + it('adds .qwen/QWEN.local.md to .gitignore on first write', async () => { + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- local entry', + projectRoot: workspace, + }); + + const gitignore = await fs.readFile( + path.join(workspace, '.gitignore'), + 'utf8', + ); + expect(gitignore).toContain(`${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`); + }); + + it('does not duplicate the gitignore entry on subsequent writes', async () => { + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- first', + projectRoot: workspace, + }); + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- second', + projectRoot: workspace, + }); + + const gitignore = await fs.readFile( + path.join(workspace, '.gitignore'), + 'utf8', + ); + const count = gitignore + .split('\n') + .filter( + (l) => l.trim() === `${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`, + ).length; + expect(count).toBe(1); + }); + }); + + describe('monorepo path resolution', () => { + it('resolves local scope to git root when projectRoot is a subdirectory', async () => { + const gitRoot = path.join(tmpRoot, 'repo'); + const subWorkspace = path.join(gitRoot, 'packages', 'cli'); + await fs.mkdir(subWorkspace, { recursive: true }); + await fs.mkdir(path.join(gitRoot, '.git'), { recursive: true }); + await fs.writeFile(path.join(gitRoot, '.git', 'HEAD'), 'ref: main\n'); + + const result = await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- monorepo local entry', + projectRoot: subWorkspace, + }); + + expect(result.filePath).toBe( + path.join(gitRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + ); + expect(result.resolvedScope).toBe('local'); + + const written = await fs.readFile(result.filePath, 'utf8'); + expect(written).toContain('- monorepo local entry'); + + // .gitignore should be at git root, not the subdirectory + const gitignore = await fs.readFile( + path.join(gitRoot, '.gitignore'), + 'utf8', + ); + expect(gitignore).toContain(`${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`); + }); + + it('resolves auto to local file at git root when no committed file exists', async () => { + const gitRoot = path.join(tmpRoot, 'repo'); + const subWorkspace = path.join(gitRoot, 'packages', 'cli'); + await fs.mkdir(subWorkspace, { recursive: true }); + await fs.mkdir(path.join(gitRoot, '.git'), { recursive: true }); + await fs.writeFile(path.join(gitRoot, '.git', 'HEAD'), 'ref: main\n'); + + const localPath = path.join(gitRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, '# local\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- auto local entry', + projectRoot: subWorkspace, + }); + + expect(result.filePath).toBe(localPath); + expect(result.resolvedScope).toBe('local'); + }); + + it('resolves auto to committed QWEN.md at git root when projectRoot is a subdirectory', async () => { + const gitRoot = path.join(tmpRoot, 'repo'); + const subWorkspace = path.join(gitRoot, 'packages', 'cli'); + await fs.mkdir(subWorkspace, { recursive: true }); + await fs.mkdir(path.join(gitRoot, '.git'), { recursive: true }); + await fs.writeFile(path.join(gitRoot, '.git', 'HEAD'), 'ref: main\n'); + await fs.writeFile( + path.join(gitRoot, DEFAULT_CONTEXT_FILENAME), + '# root\n', + 'utf8', + ); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- monorepo auto entry', + projectRoot: subWorkspace, + }); + + expect(result.filePath).toBe( + path.join(gitRoot, DEFAULT_CONTEXT_FILENAME), + ); + expect(result.resolvedScope).toBe('workspace'); + expect(result.resolvedRoot).toBe(gitRoot); + + const written = await fs.readFile(result.filePath, 'utf8'); + expect(written).toContain('# root'); + expect(written).toContain('- monorepo auto entry'); + }); + + it('resolves auto to committed AGENTS.md at git root when projectRoot is a subdirectory', async () => { + const gitRoot = path.join(tmpRoot, 'repo'); + const subWorkspace = path.join(gitRoot, 'packages', 'cli'); + await fs.mkdir(subWorkspace, { recursive: true }); + await fs.mkdir(path.join(gitRoot, '.git'), { recursive: true }); + await fs.writeFile(path.join(gitRoot, '.git', 'HEAD'), 'ref: main\n'); + await fs.writeFile( + path.join(gitRoot, AGENT_CONTEXT_FILENAME), + '# root agents\n', + 'utf8', + ); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- monorepo agents entry', + projectRoot: subWorkspace, + }); + + expect(result.filePath).toBe(path.join(gitRoot, AGENT_CONTEXT_FILENAME)); + expect(result.resolvedScope).toBe('workspace'); + expect(result.resolvedRoot).toBe(gitRoot); + + const written = await fs.readFile(result.filePath, 'utf8'); + expect(written).toContain('# root agents'); + expect(written).toContain('- monorepo agents entry'); + }); + + it('falls back to projectRoot when no git repo exists', async () => { + // workspace is already a tmpdir with no .git parent + const result = await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- no git entry', + projectRoot: workspace, + }); + + expect(result.filePath).toBe( + path.join(workspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + ); + expect(result.resolvedScope).toBe('local'); + }); + }); + + describe('resolvedScope', () => { + it('returns resolvedScope matching the requested scope for workspace', async () => { + const result = await writeWorkspaceContextFile({ + scope: 'workspace', + mode: 'append', + content: '- workspace entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('workspace'); + }); + + it('returns resolvedScope matching the requested scope for global', async () => { + const result = await writeWorkspaceContextFile({ + scope: 'global', + mode: 'append', + content: '- global entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('global'); + }); + + it('returns resolvedScope matching the requested scope for local', async () => { + const result = await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- local entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('local'); + }); + + it('resolves auto to workspace when project QWEN.md exists', async () => { + await fs.writeFile( + path.join(workspace, DEFAULT_CONTEXT_FILENAME), + '# project\n', + 'utf8', + ); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- auto workspace entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('workspace'); + expect(result.filePath).toBe( + path.join(workspace, DEFAULT_CONTEXT_FILENAME), + ); + }); + + it('resolves auto to local when only local file exists', async () => { + const localPath = getLocalContextFilePath(workspace); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, '# local\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- auto local entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('local'); + expect(result.filePath).toBe(localPath); + }); + + it('returns resolvedScope matching explicit global writes', async () => { + const result = await writeWorkspaceContextFile({ + scope: 'global', + mode: 'append', + content: '- explicit global entry', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('global'); + expect(result.filePath).toBe( + path.join(globalDir, DEFAULT_CONTEXT_FILENAME), + ); + }); + }); + + describe('auto scope safety', () => { + it('refuses mode=replace when auto resolves to global', async () => { + await expect( + writeWorkspaceContextFile({ + scope: 'auto', + mode: 'replace', + content: '- dangerous replace', + projectRoot: workspace, + }), + ).rejects.toThrow( + "scope='auto' is not allowed when auto resolves to the global directory", + ); + }); + + it('refuses mode=append when auto resolves to global', async () => { + await expect( + writeWorkspaceContextFile({ + scope: 'auto', + mode: 'append', + content: '- dangerous append', + projectRoot: workspace, + }), + ).rejects.toThrow( + "scope='auto' is not allowed when auto resolves to the global directory", + ); + }); + + it('allows mode=replace when auto resolves to workspace', async () => { + await fs.writeFile( + path.join(workspace, DEFAULT_CONTEXT_FILENAME), + '# project\n', + 'utf8', + ); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'replace', + content: '- replaced', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('workspace'); + const written = await fs.readFile(result.filePath, 'utf8'); + expect(written).toBe('- replaced'); + }); + + it('allows mode=replace when auto resolves to local', async () => { + const localPath = getLocalContextFilePath(workspace); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, '# local\n', 'utf8'); + + const result = await writeWorkspaceContextFile({ + scope: 'auto', + mode: 'replace', + content: '- replaced local', + projectRoot: workspace, + }); + + expect(result.resolvedScope).toBe('local'); + const written = await fs.readFile(result.filePath, 'utf8'); + expect(written).toBe('- replaced local'); + }); + }); + + describe('symlink safety', () => { + it('skips gitignore update when .gitignore is a symlink', async () => { + // Create a target file for the symlink + const targetFile = path.join(workspace, 'gitignore-target'); + await fs.writeFile(targetFile, 'original\n', 'utf8'); + + // Create .gitignore as a symlink to the target + const gitignorePath = path.join(workspace, '.gitignore'); + fsSync.symlinkSync(targetFile, gitignorePath); + + // Write with local scope — should NOT follow the symlink + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- local entry', + projectRoot: workspace, + }); + + // The target file should be unchanged + const targetContent = await fs.readFile(targetFile, 'utf8'); + expect(targetContent).toBe('original\n'); + + // The local file should still be written + const localPath = getLocalContextFilePath(workspace); + await expect(fs.access(localPath)).resolves.toBeUndefined(); + }); + + it('updates .gitignore normally when it is a regular file', async () => { + // Create a regular .gitignore + await fs.writeFile( + path.join(workspace, '.gitignore'), + '# ignore\n', + 'utf8', + ); + + await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- local entry', + projectRoot: workspace, + }); + + const gitignore = await fs.readFile( + path.join(workspace, '.gitignore'), + 'utf8', + ); + expect(gitignore).toContain(`${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`); + }); + + it('refuses to write when the target file is a symlink', async () => { + const targetFile = path.join(tmpRoot, 'symlink-target'); + await fs.writeFile(targetFile, 'original\n', 'utf8'); + + const localPath = getLocalContextFilePath(workspace); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + fsSync.symlinkSync(targetFile, localPath); + + await expect( + writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- entry', + projectRoot: workspace, + }), + ).rejects.toThrow(/is a symbolic link/); + + // The symlink target must be untouched + const targetContent = await fs.readFile(targetFile, 'utf8'); + expect(targetContent).toBe('original\n'); + }); + + it('refuses to write local memory when .qwen is a symlink', async () => { + const targetDir = path.join(tmpRoot, 'symlink-target-dir'); + await fs.mkdir(targetDir, { recursive: true }); + fsSync.symlinkSync(targetDir, path.join(workspace, QWEN_DIR), 'dir'); + + await expect( + writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- entry', + projectRoot: workspace, + }), + ).rejects.toThrow(/is a symlink/); + + await expect( + fs.access(path.join(targetDir, LOCAL_CONTEXT_FILENAME)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('refuses to write in replace mode when target is a symlink', async () => { + const targetFile = path.join(tmpRoot, 'symlink-target'); + await fs.writeFile(targetFile, 'original\n', 'utf8'); + + const qwenPath = path.join(workspace, DEFAULT_CONTEXT_FILENAME); + fsSync.symlinkSync(targetFile, qwenPath); + + await expect( + writeWorkspaceContextFile({ + scope: 'workspace', + mode: 'replace', + content: 'replacement', + projectRoot: workspace, + }), + ).rejects.toThrow(/is a symbolic link/); + }); + + it('allows write when target does not exist (no symlink)', async () => { + const localPath = getLocalContextFilePath(workspace); + // No pre-existing file, no symlink — should succeed + const result = await writeWorkspaceContextFile({ + scope: 'local', + mode: 'append', + content: '- fresh entry', + projectRoot: workspace, + }); + + expect(result.changed).toBe(true); + const written = await fs.readFile(localPath, 'utf8'); + expect(written).toContain('- fresh entry'); + }); + }); }); diff --git a/packages/core/src/memory/writeContextFile.ts b/packages/core/src/memory/writeContextFile.ts index ac8789a947d..93d31a63c4c 100644 --- a/packages/core/src/memory/writeContextFile.ts +++ b/packages/core/src/memory/writeContextFile.ts @@ -13,7 +13,18 @@ import { type MutexInterface, } from 'async-mutex'; import { Storage } from '../config/storage.js'; -import { getCurrentGeminiMdFilename, MEMORY_SECTION_HEADER } from './const.js'; +import { + getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, + getLocalContextFilePath, + LOCAL_CONTEXT_FILENAME, + MEMORY_SECTION_HEADER, +} from './const.js'; +import { QWEN_DIR } from '../utils/paths.js'; +import { findProjectRoot } from '../utils/memoryDiscovery.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const logger = createDebugLogger('MEMORY_WRITE'); /** * Per-resolved-file mutex map. Two simultaneous `writeWorkspaceContextFile` @@ -77,7 +88,7 @@ export class WorkspaceMemoryWriteTimeoutError extends Error { } } -export type WriteContextFileScope = 'workspace' | 'global'; +export type WriteContextFileScope = 'workspace' | 'global' | 'local' | 'auto'; export type WriteContextFileMode = 'append' | 'replace'; export interface WriteContextFileOptions { @@ -90,14 +101,29 @@ export interface WriteContextFileOptions { */ content: string; /** - * Absolute path to the workspace root (used when `scope === 'workspace'`). - * Ignored for `global` writes. + * Absolute path to the workspace root. Required for `'workspace'` + * and `'auto'` scopes. Ignored for `'global'` writes. */ projectRoot: string; } export interface WriteContextFileResult { filePath: string; + /** + * The scope that was actually written to, resolved from the + * requested `scope`. When `scope: 'auto'` is used, this reflects + * the concrete scope (`'workspace'`, `'local'`, or `'global'`) + * that the auto-detection resolved to. `'auto'` never appears + * here — it is always resolved to a concrete value. + */ + resolvedScope: 'workspace' | 'local' | 'global'; + /** + * The resolved project root used for path resolution. For + * `scope: 'local'` and `scope: 'auto'` (when resolving to a + * file at the git root), this may differ from the requested + * `projectRoot` in monorepo layouts. + */ + resolvedRoot: string; /** * Bytes actually written by this call. `0` on the no-op short- * circuit path (`changed: false`). NOT a measurement of the file's @@ -144,7 +170,21 @@ export async function writeWorkspaceContextFile( `writeWorkspaceContextFile: projectRoot must be absolute, got "${options.projectRoot}"`, ); } - const filePath = resolveContextFilePath(options.scope, options.projectRoot); + const { filePath, resolvedScope, resolvedRoot } = + await resolveContextFilePath(options.scope, options.projectRoot); + + // Safety guard: refuse any `auto` write that resolves to global. + // Project-specific memory sent through `auto` should not silently + // land in the user's global file. Require an explicit global scope + // when the caller really wants to touch cross-workspace memory. + if (options.scope === 'auto' && resolvedScope === 'global') { + throw new Error( + `scope='auto' is not allowed when auto resolves ` + + `to the global directory. Use scope='global' to explicitly target ` + + `the global memory file, or create a project QWEN.md or ` + + `${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME} first.`, + ); + } // Hold the per-file mutex for the entire read-compose-write sequence // INCLUDING the whitespace-only no-op detection. Two concurrent @@ -158,7 +198,8 @@ export async function writeWorkspaceContextFile( // composite. try { return await getFileLock(filePath).runExclusive( - async () => await runWrite(filePath, options), + async () => + await runWrite(filePath, options, resolvedScope, resolvedRoot), ); } catch (err) { // `withTimeout` rejects with the `E_TIMEOUT` sentinel when the @@ -179,6 +220,8 @@ export async function writeWorkspaceContextFile( async function runWrite( filePath: string, options: WriteContextFileOptions, + resolvedScope: 'workspace' | 'local' | 'global', + resolvedRoot: string, ): Promise { if (options.mode === 'append' && isWhitespaceOnly(options.content)) { // No-op short-circuit. Skip the mkdir + writeFile path entirely @@ -194,49 +237,272 @@ async function runWrite( // added in for every whitespace POST. `changed: false` already // gives clients the no-op signal; the byte count should remain // true to its field name. - return { filePath, bytesWritten: 0, changed: false }; + return { + filePath, + resolvedScope, + resolvedRoot, + bytesWritten: 0, + changed: false, + }; + } + + // Ensure the local context file is gitignored before writing. Covers + // both explicit `scope='local'` and `scope='auto'` that resolved to + // the local file path (no committed QWEN.md/AGENTS.md exists but + // .qwen/QWEN.local.md does). Placed after the no-op guard so a + // whitespace-only append never touches .gitignore, and wrapped so a + // permission/read-only failure (EACCES/EROFS) degrades to a warning + // rather than aborting the memory write itself. + if (resolvedScope === 'local') { + try { + await ensureGitignoreEntry(resolvedRoot); + } catch (err) { + logger.warn( + `Failed to ensure .gitignore entry for ${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + if (resolvedScope === 'local') { + // Verify .qwen/ is not a symlink (parent-directory attack mitigation). + // A malicious repo can ship `.qwen/` as a symlink to `~/.ssh/` or + // `~/.config/`, causing local writes to escape the repository. We + // check before `fs.mkdir` so the directory is not traversed when + // it's a symlink. + const qwenDir = path.dirname(filePath); + try { + const dirLstat = await fs.lstat(qwenDir); + if (dirLstat.isSymbolicLink()) { + throw new Error( + `Refusing to write: ${qwenDir} is a symlink. ` + + `A symlink here could redirect the write to an arbitrary location. ` + + `Remove the symlink and retry.`, + ); + } + } catch (err) { + if (isEnoent(err)) { + // Directory doesn't exist yet — that's fine, we'll create it below. + } else { + throw err; + } + } } await fs.mkdir(path.dirname(filePath), { recursive: true }); if (options.mode === 'replace') { + await assertNotSymlink(filePath); await fs.writeFile(filePath, options.content, { encoding: 'utf8', mode: 0o644, }); return { filePath, + resolvedScope, + resolvedRoot, bytesWritten: Buffer.byteLength(options.content, 'utf8'), changed: true, }; } + await assertNotSymlink(filePath); const next = await composeAppendedContent(filePath, options.content); + await assertNotSymlink(filePath); await fs.writeFile(filePath, next, { encoding: 'utf8', mode: 0o644 }); return { filePath, + resolvedScope, + resolvedRoot, bytesWritten: Buffer.byteLength(next, 'utf8'), changed: true, }; } -function resolveContextFilePath( +/** + * Security guard: refuse to write through a symlink. A malicious repo + * can ship `.qwen/QWEN.local.md` (or any target file) as a symlink + * pointing to `~/.ssh/authorized_keys`, `~/.bashrc`, etc. Without this + * check, `fs.writeFile` would follow the symlink and corrupt the target. + * + * ENOENT is expected and safe — the file doesn't exist yet and we are + * about to create it. Any other error (EACCES, EPERM) is rethrown so + * the caller knows the filesystem state could not be verified. + */ +async function assertNotSymlink(filePath: string): Promise { + try { + const lstat = await fs.lstat(filePath); + if (lstat.isSymbolicLink()) { + throw new Error( + `Refusing to write: ${filePath} is a symbolic link. ` + + `Remove the symlink and try again.`, + ); + } + } catch (err) { + if (isEnoent(err)) { + return; + } + throw err; + } +} + +/** + * Idempotently add `.qwen/QWEN.local.md` to `.gitignore` so the local + * context file is never accidentally committed. Safe to call on every + * local-scope write — exits early when the pattern is already present. + * + * Symlink safety: uses `lstat` to detect and refuse to follow symlinks. + * A malicious repository can ship `.gitignore` as a symlink pointing to + * `~/.bashrc`, `~/.config/git/config`, or any other user-writable file. + * Without this check, the first `scope='local'` write would append to + * the symlink target, corrupting it. + */ +async function ensureGitignoreEntry(projectRoot: string): Promise { + const entry = `${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`; + const gitignorePath = path.join(projectRoot, '.gitignore'); + + try { + const lstat = await fs.lstat(gitignorePath); + if (lstat.isSymbolicLink()) { + logger.warn( + `.gitignore at ${gitignorePath} is a symlink; skipping gitignore update.`, + ); + return; + } + } catch (err) { + // ENOENT — file doesn't exist yet, create it below. + // Any other error (EACCES, EPERM) is thrown to the caller. + if ( + (typeof err === 'object' && + err !== null && + (err as { code?: string }).code) !== 'ENOENT' + ) { + throw err; + } + } + + try { + const existing = await fs.readFile(gitignorePath, 'utf8'); + if (existing.split('\n').some((line) => line.trim() === entry)) { + return; + } + const sep = existing.endsWith('\n') ? '' : '\n'; + await fs.writeFile(gitignorePath, existing + sep + entry + '\n', 'utf8'); + } catch (err) { + if (isEnoent(err)) { + await fs.writeFile(gitignorePath, entry + '\n', 'utf8'); + } else { + throw err; + } + } +} + +async function resolveContextFilePath( scope: WriteContextFileScope, projectRoot: string, -): string { +): Promise<{ + filePath: string; + resolvedScope: 'workspace' | 'local' | 'global'; + resolvedRoot: string; +}> { + const gitRoot = (await findProjectRoot(projectRoot)) ?? projectRoot; // Honor `setGeminiMdFilename()` overrides so POST writes to the same - // file GET surfaces. With the prior `DEFAULT_CONTEXT_FILENAME` hard- - // code, a deployment that switched the context filename to - // `AGENTS.md` would have GET listing the new file while POST kept - // appending to a stale `QWEN.md` — clients then observed "I just - // wrote content but it's missing from /workspace/memory". Mirrors the - // discovery path's `getAllGeminiMdFilenames()` usage in - // `workspaceMemory.ts:collectWorkspaceMemoryStatus`. + // file GET surfaces. const filename = getCurrentGeminiMdFilename(); if (scope === 'workspace') { - return path.join(projectRoot, filename); + return { + filePath: path.join(projectRoot, filename), + resolvedScope: 'workspace', + resolvedRoot: projectRoot, + }; } - return path.join(Storage.getGlobalQwenDir(), filename); + + if (scope === 'local') { + return { + filePath: getLocalContextFilePath(gitRoot), + resolvedScope: 'local', + resolvedRoot: gitRoot, + }; + } + + if (scope === 'auto') { + // Issue #359: auto-detect project-level context file. Checks known + // filenames at project root; falls back to global ~/.qwen/ when + // none exist. Note: a TOCTOU window exists between this resolution + // and the actual write — the per-file mutex serializes writes to + // the same path but doesn't protect the scope decision itself. + const candidates = getAllGeminiMdFilenames(); + for (const candidateName of candidates) { + const candidatePath = path.join(projectRoot, candidateName); + try { + const stat = await fs.stat(candidatePath); + if (stat.isFile()) { + return { + filePath: candidatePath, + resolvedScope: 'workspace', + resolvedRoot: projectRoot, + }; + } + } catch (err) { + if (!isEnoent(err)) throw err; + } + } + // Also check at gitRoot when it differs from projectRoot — covers + // monorepo layouts where QWEN.md/AGENTS.md lives at the repo root + // but projectRoot is a subdirectory. + if (gitRoot !== projectRoot) { + for (const candidateName of candidates) { + const candidatePath = path.join(gitRoot, candidateName); + try { + const stat = await fs.stat(candidatePath); + if (stat.isFile()) { + return { + filePath: candidatePath, + resolvedScope: 'workspace', + resolvedRoot: gitRoot, + }; + } + } catch (err) { + if (!isEnoent(err)) throw err; + } + } + } + // Check for local file at the git root before falling back to global. + const localPath = getLocalContextFilePath(gitRoot); + try { + const stat = await fs.stat(localPath); + if (stat.isFile()) { + return { + filePath: localPath, + resolvedScope: 'local', + resolvedRoot: gitRoot, + }; + } + } catch (err) { + if (!isEnoent(err)) throw err; + } + // Fallback to global — log a warning so the operator knows auto + // did not find a project-level file. This is a data safety signal: + // project-specific content written via `auto` will leak into every + // future session for every project. + logger.warn( + `scope='auto' resolved to global (~/.qwen/${filename}) — no project-level ` + + `context file found at ${projectRoot}. Consider creating a project QWEN.md ` + + `or using scope='local' for project-scoped memory.`, + ); + return { + filePath: path.join(Storage.getGlobalQwenDir(), filename), + resolvedScope: 'global', + resolvedRoot: projectRoot, + }; + } + + return { + filePath: path.join(Storage.getGlobalQwenDir(), filename), + resolvedScope: 'global', + resolvedRoot: projectRoot, + }; } /** diff --git a/packages/core/src/tools/memory-config.ts b/packages/core/src/tools/memory-config.ts index 757c2bdcf89..62591c3e166 100644 --- a/packages/core/src/tools/memory-config.ts +++ b/packages/core/src/tools/memory-config.ts @@ -5,43 +5,18 @@ */ /** - * Lightweight configuration for memory/context file naming. - * Extracted from memoryTool.ts to avoid loading the full tool module - * when only the filename configuration is needed. + * Re-exports from the canonical source at `memory/const.ts`. + * This file exists only for backward compatibility with test mocks + * that reference `../tools/memory-config`. Runtime code should + * import directly from `memory/const.js` instead. */ -export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; -export const AGENT_CONTEXT_FILENAME = 'AGENTS.md'; -export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; - -// This variable will hold the currently configured filename for context files. -// It defaults to include both QWEN.md and AGENTS.md but can be overridden by setGeminiMdFilename. -// QWEN.md is first to maintain backward compatibility (used by /init command and save_memory tool). -let currentGeminiMdFilename: string | string[] = [ +export { DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME, -]; - -export function setGeminiMdFilename(newFilename: string | string[]): void { - if (Array.isArray(newFilename)) { - if (newFilename.length > 0) { - currentGeminiMdFilename = newFilename.map((name) => name.trim()); - } - } else if (newFilename && newFilename.trim() !== '') { - currentGeminiMdFilename = newFilename.trim(); - } -} - -export function getCurrentGeminiMdFilename(): string { - if (Array.isArray(currentGeminiMdFilename)) { - return currentGeminiMdFilename[0]; - } - return currentGeminiMdFilename; -} - -export function getAllGeminiMdFilenames(): string[] { - if (Array.isArray(currentGeminiMdFilename)) { - return currentGeminiMdFilename; - } - return [currentGeminiMdFilename]; -} + LOCAL_CONTEXT_FILENAME, + MEMORY_SECTION_HEADER, + setGeminiMdFilename, + getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, +} from '../memory/const.js'; diff --git a/packages/core/src/utils/ignorePatterns.test.ts b/packages/core/src/utils/ignorePatterns.test.ts index 8c1aed00478..87fec3f34da 100644 --- a/packages/core/src/utils/ignorePatterns.test.ts +++ b/packages/core/src/utils/ignorePatterns.test.ts @@ -15,6 +15,11 @@ import type { Config } from '../config/config.js'; // Mock the memoryTool module vi.mock('../memory/const.js', () => ({ getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']), + LOCAL_CONTEXT_FILENAME: 'QWEN.local.md', +})); + +vi.mock('./paths.js', () => ({ + QWEN_DIR: '.qwen', })); describe('FileExclusions', () => { @@ -57,6 +62,7 @@ describe('FileExclusions', () => { // Should include dynamic patterns expect(patterns).toContain('**/GEMINI.md'); expect(patterns).toContain('**/AGENTS.md'); + expect(patterns).toContain('**/.qwen/QWEN.local.md'); }); it('should respect includeDefaults option', () => { @@ -104,8 +110,10 @@ describe('FileExclusions', () => { expect(patternsWithDynamic).toContain('**/GEMINI.md'); expect(patternsWithDynamic).toContain('**/AGENTS.md'); + expect(patternsWithDynamic).toContain('**/.qwen/QWEN.local.md'); expect(patternsWithoutDynamic).not.toContain('**/GEMINI.md'); expect(patternsWithoutDynamic).not.toContain('**/AGENTS.md'); + expect(patternsWithoutDynamic).not.toContain('**/.qwen/QWEN.local.md'); }); }); diff --git a/packages/core/src/utils/ignorePatterns.ts b/packages/core/src/utils/ignorePatterns.ts index 9823ddcf485..c204cf33e31 100644 --- a/packages/core/src/utils/ignorePatterns.ts +++ b/packages/core/src/utils/ignorePatterns.ts @@ -6,7 +6,11 @@ import path from 'node:path'; import type { Config } from '../config/config.js'; -import { getAllGeminiMdFilenames } from '../memory/const.js'; +import { + getAllGeminiMdFilenames, + LOCAL_CONTEXT_FILENAME, +} from '../memory/const.js'; +import { QWEN_DIR } from './paths.js'; /** * Common ignore patterns used across multiple tools for basic exclusions. @@ -163,6 +167,7 @@ export class FileExclusions { for (const filename of getAllGeminiMdFilenames()) { patterns.push(`**/${filename}`); } + patterns.push(`**/${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`); } // Add custom patterns from configuration diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index a61138ce246..c1becd8f4fc 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -12,6 +12,8 @@ import { loadServerHierarchicalMemory } from './memoryDiscovery.js'; import { setGeminiMdFilename, DEFAULT_CONTEXT_FILENAME, + AGENT_CONTEXT_FILENAME, + LOCAL_CONTEXT_FILENAME, } from '../memory/const.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { QWEN_DIR } from './paths.js'; @@ -536,4 +538,191 @@ describe('loadServerHierarchicalMemory', () => { expect(parentOccurrences).toBe(1); expect(childOccurrences).toBe(1); }); + + describe('.qwen/QWEN.local.md', () => { + // These tests need a real git root so findProjectRoot resolves correctly. + beforeEach(async () => { + await fsPromises.mkdir(path.join(projectRoot, '.git'), { + recursive: true, + }); + await fsPromises.writeFile( + path.join(projectRoot, '.git', 'HEAD'), + 'ref: main\n', + ); + }); + + it('loads local context file when present at project root', async () => { + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local instructions', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain('local instructions'); + expect(result.memoryContent).toContain( + path.relative(cwd, localFile).toString(), + ); + }); + + it('does not load local context file when it is a symlink', async () => { + const sensitiveFile = await createTestFile( + path.join(testRootDir, 'sensitive.txt'), + 'sensitive instructions', + ); + const localPath = path.join( + projectRoot, + QWEN_DIR, + LOCAL_CONTEXT_FILENAME, + ); + await fsPromises.mkdir(path.dirname(localPath), { recursive: true }); + await fsPromises.symlink(sensitiveFile, localPath); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).not.toContain('sensitive instructions'); + }); + + it('places local file between committed and global files', async () => { + await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'committed content', + ); + await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local content', + ); + await createTestFile( + path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME), + 'global content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(3); + + const committedIdx = result.memoryContent!.indexOf('committed content'); + const localIdx = result.memoryContent!.indexOf('local content'); + const globalIdx = result.memoryContent!.indexOf('global content'); + // Order in prompt: global → local → committed (later = higher priority). + expect(globalIdx).toBeLessThan(localIdx); + expect(localIdx).toBeLessThan(committedIdx); + }); + + it('keeps every global file ahead of the local file with interleaved filenames', async () => { + // Two configured filenames produce an interleaved discovery list + // (e.g. [global/QWEN.md, committed/QWEN.md, global/AGENTS.md]). The + // local file must land after ALL global entries, not just the first + // non-global — otherwise global/AGENTS.md ends up behind local. + setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); + await createTestFile( + path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME), + 'global qwen content', + ); + await createTestFile( + path.join(homedir, QWEN_DIR, AGENT_CONTEXT_FILENAME), + 'global agents content', + ); + await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'committed content', + ); + await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + const content = result.memoryContent!; + const localIdx = content.indexOf('local content'); + expect(localIdx).toBeGreaterThan(content.indexOf('global qwen content')); + expect(localIdx).toBeGreaterThan( + content.indexOf('global agents content'), + ); + }); + + it('does not load local file for untrusted workspaces', async () => { + await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local instructions', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + false, // untrusted + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).toBe(''); + }); + + it('does not load local file in explicitOnly mode', async () => { + await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local instructions', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + 'tree', + [], + { explicitOnly: true }, + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).toBe(''); + }); + + it('loads local file when no committed QWEN.md exists', async () => { + await createTestFile( + path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME), + 'local-only instructions', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain('local-only instructions'); + }); + }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index ef7f43c6d49..8e5e496ad32 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -8,7 +8,11 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; -import { getAllGeminiMdFilenames } from '../memory/const.js'; +import { + getAllGeminiMdFilenames, + getLocalContextFilePath, + LOCAL_CONTEXT_FILENAME, +} from '../memory/const.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; import { QWEN_DIR } from './paths.js'; @@ -23,14 +27,32 @@ interface GeminiFileContent { content: string | null; } -async function findProjectRoot(startDir: string): Promise { +/** + * Walks up the directory tree from `startDir` looking for a `.git` + * directory. Returns the first directory containing `.git`, or `null` + * if the filesystem root is reached without finding one. + * + * Exported so callers that need to resolve the project root (e.g. + * `workspaceMemory.ts` for the local file probe) can reuse the same + * logic as the discovery path, avoiding monorepo path mismatches. + */ +export async function findProjectRoot( + startDir: string, +): Promise { let currentDir = path.resolve(startDir); while (true) { const gitPath = path.join(currentDir, '.git'); try { const stats = await fs.lstat(gitPath); if (stats.isDirectory()) { - return currentDir; + try { + await fs.access(path.join(gitPath, 'HEAD'), fsSync.constants.R_OK); + return currentDir; + } catch { + // Ignore empty placeholder .git directories. Some sandboxed + // environments expose one at /tmp, which should not make every + // temp workspace resolve to /tmp as the project root. + } } } catch (error: unknown) { // Don't log ENOENT errors as they're expected when .git doesn't exist @@ -373,6 +395,57 @@ export async function loadServerHierarchicalMemory( implicitDiscoveryEnabled, ); + // Compute effective project root for local file probe and rules. + const resolvedCwd = path.resolve(currentWorkingDirectory); + const foundRoot = await findProjectRoot(resolvedCwd); + const effectiveRoot = foundRoot ?? resolvedCwd; + + // Probe for .qwen/QWEN.local.md — personal, gitignored instructions. + // Separate scope from committed files; inserted between global and committed. + if (folderTrust && !options.explicitOnly) { + const localPath = getLocalContextFilePath(effectiveRoot); + try { + const localLstat = await fs.lstat(localPath); + if (localLstat.isSymbolicLink()) { + logger.warn(`Skipping symlinked local context file: ${localPath}`); + } else { + await fs.access(localPath, fsSync.constants.R_OK); + const globalQwenDir = Storage.getGlobalQwenDir(); + // filePaths is interleaved per configured filename, e.g. + // [global/QWEN.md, workspace/QWEN.md, global/AGENTS.md, ...]. + // Insert local AFTER the last global path so every global file + // stays ahead of it (global → local → committed). Inserting before + // the first non-global would strand later global entries (e.g. + // global/AGENTS.md) behind the local file. + let lastGlobalIdx = -1; + for (let i = 0; i < filePaths.length; i++) { + if (filePaths[i].startsWith(globalQwenDir)) { + lastGlobalIdx = i; + } + } + filePaths.splice(lastGlobalIdx + 1, 0, localPath); + logger.debug(`Found local context file: ${localPath}`); + } + } catch (err) { + // ENOENT is the common case — file doesn't exist, skip silently. + // Log non-ENOENT errors (EACCES, EPERM, EIO) so the operator + // can diagnose permission issues; don't swallow them silently. + const code = + typeof err === 'object' && err !== null + ? (err as { code?: string }).code + : undefined; + if (code === 'ENOENT') { + // Not found — expected. + } else { + logger.warn( + `Could not access local context file ${localPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + } + let combinedInstructions = ''; let fileCount = 0; @@ -384,19 +457,15 @@ export async function loadServerHierarchicalMemory( currentWorkingDirectory, ); - // Only count files that match configured memory filenames (e.g., QWEN.md), - // excluding system context files like output-language.md + // Count files matching configured memory filenames and the local context file. const memoryFilenames = new Set(getAllGeminiMdFilenames()); + memoryFilenames.add(LOCAL_CONTEXT_FILENAME); fileCount = contentsWithPaths.filter((item) => memoryFilenames.has(path.basename(item.filePath)), ).length; } // Load path-based context rules from .qwen/rules/ directories - const resolvedCwd = path.resolve(currentWorkingDirectory); - const foundRoot = await findProjectRoot(resolvedCwd); - const effectiveRoot = foundRoot ?? resolvedCwd; - const { content: rulesContent, ruleCount, diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 7e2ee1b98e9..f8d5bc0c703 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -212,12 +212,13 @@ export interface DaemonMcpChildRefusedBatchData { /** * Issue #4175 PR 16: a `POST /workspace/memory` write completed - * successfully. `scope` records which file was touched (workspace QWEN.md - * vs global ~/.qwen/QWEN.md), `mode` is the requested write mode, and - * `bytesWritten` is the size of the file post-write. + * successfully. `scope` records which file was touched (workspace QWEN.md, + * `.qwen/QWEN.local.md`, or global ~/.qwen/QWEN.md). `mode` is the + * requested write mode, and `bytesWritten` is the size of the file + * post-write. */ export interface DaemonMemoryChangedData { - scope: 'workspace' | 'global'; + scope: 'workspace' | 'local' | 'global'; filePath: string; mode: 'append' | 'replace'; bytesWritten: number; @@ -1512,7 +1513,7 @@ function isMemoryChangedData(value: unknown): value is DaemonMemoryChangedData { const scope = value['scope']; const mode = value['mode']; return ( - (scope === 'workspace' || scope === 'global') && + (scope === 'workspace' || scope === 'local' || scope === 'global') && isNonEmptyString(value['filePath']) && (mode === 'append' || mode === 'replace') && isFiniteNumber(value['bytesWritten']) diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index a656103263a..a49275cbfc3 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -353,7 +353,7 @@ export interface DaemonWorkspaceProvidersStatus { * pattern used by mcp/skills/providers — adapters can render any of * the four with the same component. */ -export type DaemonContextFileScope = 'workspace' | 'global'; +export type DaemonContextFileScope = 'workspace' | 'local' | 'global'; export interface DaemonWorkspaceMemoryFile { kind: 'memory_file'; diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 779a9f2a672..16ee1b39a7e 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -146,6 +146,39 @@ describe('daemon event schema', () => { ).toBeUndefined(); }); + it.each(['workspace', 'local', 'global'] as const)( + 'accepts memory_changed events for resolved %s scope', + (scope) => { + const event: DaemonEvent = { + v: 1, + type: 'memory_changed', + data: { + scope, + filePath: '/workspace/QWEN.md', + mode: 'append', + bytesWritten: 42, + }, + }; + + expect(asKnownDaemonEvent(event)).toBe(event); + }, + ); + + it('rejects memory_changed events with unresolved auto scope', () => { + expect( + asKnownDaemonEvent({ + v: 1, + type: 'memory_changed', + data: { + scope: 'auto', + filePath: '/workspace/QWEN.md', + mode: 'append', + bytesWritten: 42, + }, + }), + ).toBeUndefined(); + }); + it('reduces permission, model, and terminal events into a session view', () => { const state = reduceDaemonSessionEvents([ { diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 0006bd5427d..226bb540afc 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -197,6 +197,12 @@ This file contains third-party software notices and license terms. limitations under the License. +============================================================ +@qwen-code/sdk@undefined +(https://github.com/QwenLM/qwen-code.git) + +License text not found. + ============================================================ @qwen-code/webui@undefined (No repository found)