From f1879c0b4940c4c5604bfdddbe042d28d9a1be4e Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 14:17:48 +0800 Subject: [PATCH 01/31] feat(loop): inject a .qwen/loop.md task file at fire time via sentinels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long-running /loop had no durable place to keep its task list — the model re-stated the work every tick. This adds a .qwen/loop.md file the loop re-reads and injects at fire time, driven by a sentinel prompt (<> for self-paced LoopWakeup, <> for fixed-interval CronCreate). At fire time the sentinel expands into the full task block (first delivery, after the file changes, or after a compaction) or a short reminder (unchanged), so the list is paid for once into the cached prefix and later ticks stay cheap. Change-detection is content equality, and the cache is committed only after the tick is delivered so an aborted tick can't leave a dangling reminder. The reader is workspace-confined (realpath boundary), 25 KB capped, and skips empty/missing/symlinked candidates. Interactive (Session) wiring covers self-paced and fixed-interval loops; headless skips a bare sentinel (full headless expansion is a follow-up). The loop.md-absent branch is a minimal no-op tick. Closes #5889 Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 128 ++++++++++ .../src/acp-integration/session/Session.ts | 68 +++++- packages/cli/src/nonInteractiveCli.ts | 10 + packages/core/src/index.ts | 2 + .../core/src/skills/bundled/loop/SKILL.md | 9 + .../src/skills/bundled/loop/SKILL.test.ts | 9 + .../skills/bundled/loop/loopTaskFile.test.ts | 221 ++++++++++++++++++ .../src/skills/bundled/loop/loopTaskFile.ts | 117 ++++++++++ .../bundled/loop/loopTickResolver.test.ts | 201 ++++++++++++++++ .../skills/bundled/loop/loopTickResolver.ts | 142 +++++++++++ 10 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/skills/bundled/loop/loopTaskFile.test.ts create mode 100644 packages/core/src/skills/bundled/loop/loopTaskFile.ts create mode 100644 packages/core/src/skills/bundled/loop/loopTickResolver.test.ts create mode 100644 packages/core/src/skills/bundled/loop/loopTickResolver.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index a7fe80938a7..b32eaf676ff 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4256,6 +4256,134 @@ describe('Session', () => { }); }); + it('expands a loop.md sentinel into the task block and echoes a clean label', async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-session-'), + ); + const loopMdPath = path.join(tmpDir, '.qwen', 'loop.md'); + await fs.mkdir(path.dirname(loopMdPath), { recursive: true }); + await fs.writeFile(loopMdPath, '- finish the migration'); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ + prompt: '<>', + cronExpr: '@wakeup', + }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(createEmptyStream()); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The client sees a stable label, never the raw sentinel. + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: `Loop tick — tasks from ${loopMdPath}`, + }, + _meta: { source: 'loop' }, + }, + }); + }); + + // The model receives the expanded full task block, not the sentinel. + let block = ''; + await vi.waitFor(() => { + const cronCall = ( + mockChat.sendMessageStream as ReturnType + ).mock.calls.find( + (c) => + Array.isArray(c[1]?.message) && + c[1].message.some((p: { text?: string }) => + p.text?.includes('finish the migration'), + ), + ); + expect(cronCall).toBeDefined(); + block = (cronCall![1].message as Array<{ text?: string }>) + .map((p) => p.text ?? '') + .join(''); + }); + expect(block).toContain('# /loop tick — tasks from'); + expect(block).toContain('- finish the migration'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('leaves a non-sentinel cron prompt untouched (no loop.md expansion)', async () => { + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ + prompt: 'do the normal cron thing', + cronExpr: '0 * * * *', + }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'do the normal cron thing' }, + _meta: { source: 'cron' }, + }, + }); + }); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : [])) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + await vi.waitFor(() => { + expect(sentToModel()).toContain('do the normal cron thing'); + }); + expect(sentToModel()).not.toContain('# /loop tick'); + }); + it('stops cron-fired ACP prompt before sending when the session token limit is exceeded', async () => { let cronCallback: ((job: { prompt: string }) => void) | undefined; const scheduler = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 5c0f0292dde..e0600c5475e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as os from 'node:os'; import type { Content, FunctionCall, @@ -28,11 +29,14 @@ import type { GoalTerminalEvent, ToolCallRequestInfo, ToolCallResponseInfo, + LoopMode, } from '@qwen-code/qwen-code-core'; import { AuthType, ApprovalMode, CompressionStatus, + detectLoopSentinel, + LoopTickResolver, convertToFunctionResponse, createDuplicateProviderToolCallResponse, createDebugLogger, @@ -664,6 +668,13 @@ export class Session implements SessionContext { private cronQueue: CronQueueItem[] = []; private cronProcessing = false; private cronAbortController: AbortController | null = null; + // Resolves the `<>` / `<>` sentinels at fire time. + // Lazily created on the first loop tick; its content cache is reset on + // compaction (see #sendMessageStreamWithAutoCompression) and it is rebuilt if + // the working dir changes (e.g. /cd) so it always reads the current project's + // loop.md. + private loopTickResolver: LoopTickResolver | null = null; + private loopTickResolverRoot: string | null = null; private cronCompletion: Promise | null = null; private cronDisabledByTokenLimit = false; private lastPromptTokenCount = 0; @@ -1926,6 +1937,10 @@ export class Session implements SessionContext { compressionInfo = compressed; this.#recordCompressionTokenCount(compressed); if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { + // Context was just compacted; a loop.md tick must re-deliver the full + // task block (a short reminder refers back to a message that is no + // longer in context). + this.loopTickResolver?.resetCache(); const reasonClause = compressed.triggerReason === 'image_overflow' ? `accumulated enough tool screenshots to trigger compaction for ${this.config.getModel()}` @@ -2427,6 +2442,20 @@ export class Session implements SessionContext { } } + #getLoopTickResolver(): LoopTickResolver { + const root = this.config.getWorkingDir(); + // Rebuild if the working dir changed (e.g. /cd) so loop.md resolves against + // the current project; a fresh resolver also correctly re-delivers full. + if (!this.loopTickResolver || this.loopTickResolverRoot !== root) { + this.loopTickResolver = new LoopTickResolver({ + projectRoot: root, + homeDir: os.homedir(), + }); + this.loopTickResolverRoot = root; + } + return this.loopTickResolver; + } + /** * Executes a single cron-fired prompt: echoes it as a user message with * `_meta.source='cron'`, streams the model response, and handles tool calls. @@ -2460,10 +2489,38 @@ export class Session implements SessionContext { async () => { let turnCount = 0; try { + // A `<>` / `<>` sentinel is expanded at + // fire time into the loop.md task block — full on the first or a + // changed fire, a short reminder when unchanged. Non-sentinel + // prompts pass through untouched. + const loopMode = detectLoopSentinel(prompt); + const loopTick = loopMode + ? await this.#getLoopTickResolver().resolve(loopMode) + : null; + const modelText = loopTick ? loopTick.modelText : prompt; + if (loopTick) { + debugLogger.debug( + `loop tick: mode=${loopMode} delivery=${ + loopTick.full + ? 'full' + : loopTick.sourcePath + ? 'reminder' + : 'absent' + } path=${loopTick.sourcePath ?? 'none'}`, + ); + } + // For a loop tick echo a stable label, never the bare sentinel or + // the full task dump; otherwise echo the prompt verbatim. + const echoText = loopTick + ? loopTick.sourcePath + ? `Loop tick — tasks from ${loopTick.sourcePath}` + : 'Loop tick — loop.md not present' + : prompt; + // Echo the cron prompt as a user message so the client sees it await this.sendUpdate({ sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: prompt }, + content: { type: 'text', text: echoText }, _meta: { source: item.source }, }); @@ -2472,7 +2529,7 @@ export class Session implements SessionContext { const cronReminders = await this.#buildInitialSystemReminders(); let nextMessage: Content | null = { role: 'user', - parts: [...cronReminders, { text: prompt }], + parts: [...cronReminders, { text: modelText }], }; while (nextMessage !== null) { @@ -2501,6 +2558,13 @@ export class Session implements SessionContext { return; } const responseStream = sendResult.responseStream; + if (loopTick && turnCount === 1) { + // The block reached the model (the send started); commit it so + // the next tick can detect "unchanged". Deferring the commit + // to here keeps an abort before delivery from poisoning the + // cache into a dangling short reminder. + this.loopTickResolver?.markDelivered(); + } nextMessage = null; for await (const resp of responseStream) { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 24af0d19d3a..86d20caad8f 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -25,6 +25,7 @@ import { uiTelemetryService, parseAndFormatApiError, createDebugLogger, + detectLoopSentinel, SendMessageType, restoreWorktreeContext, TeamEventType, @@ -1521,6 +1522,15 @@ export async function runNonInteractive( }; scheduler.start((job: { prompt: string; cronExpr?: string }) => { + // loop.md sentinel expansion is interactive-only for now; in a + // headless run a bare `<>` sentinel would reach the + // model as its prompt with no task content, so skip the tick + // (no-op) instead of enqueuing it. Full headless loop.md support + // is a follow-up. + if (detectLoopSentinel(job.prompt)) { + checkCronDone(); + return; + } const label = job.prompt.slice(0, 40); localQueue.push({ displayText: `${job.cronExpr === '@wakeup' ? 'Loop' : 'Cron'}: ${label}`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 023a8d5149a..a15584e4872 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,6 +186,8 @@ export { export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export type { DurableCronTask } from './services/cronTasksFile.js'; +export * from './skills/bundled/loop/loopTaskFile.js'; +export * from './skills/bundled/loop/loopTickResolver.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md index 761ffa09685..3a1bb979ff8 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.md +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -87,4 +87,13 @@ If the interval does not cleanly divide its unit (for example `7m` gives uneven - If it is a slash command, invoke it via the Skill tool. - Otherwise, act on it directly. +## loop.md task-file mode + +Use this when the user wants the loop to work a task list kept in a file (they say "work through my loop.md", "loop over the tasks in .qwen/loop.md", or point at such a file). Tasks live in `.qwen/loop.md` (project) or `~/.qwen/loop.md` (home; project wins). Instead of a natural-language prompt, set the loop's `prompt` to a sentinel so each fire re-reads the file: + +- Self-paced (no interval) → LoopWakeup `prompt`: `<>` +- Fixed interval → CronCreate `prompt`: `<>` (with `recurring: true`, and `durable: true` if persistence is implied) + +At each fire you receive either the full task list (first delivery, after the file changes, or after a compaction) or a short reminder to keep working the list established earlier. Work the tasks; in self-paced mode re-arm LoopWakeup with `<>` only when continued follow-up is useful (same "don't re-arm if complete/blocked" rules as the prompt-only path). If `.qwen/loop.md` is absent at fire time, treat the tick as a no-op. Confirm to the user in plain language ("looping over your `.qwen/loop.md` task list…"), not the raw sentinel. + ## Input diff --git a/packages/core/src/skills/bundled/loop/SKILL.test.ts b/packages/core/src/skills/bundled/loop/SKILL.test.ts index cabaefaa2b8..222839b242f 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.test.ts +++ b/packages/core/src/skills/bundled/loop/SKILL.test.ts @@ -64,4 +64,13 @@ describe('bundled loop skill', () => { expect(body).toContain('**`clear`** — call CronList'); expect(body).toContain('call CronDelete for every job returned'); }); + + it('documents loop.md task-file mode and the two sentinels', () => { + const { body } = loadLoopSkill(); + + expect(body).toContain('## loop.md task-file mode'); + expect(body).toContain('.qwen/loop.md'); + expect(body).toContain('`<>`'); + expect(body).toContain('`<>`'); + }); }); diff --git a/packages/core/src/skills/bundled/loop/loopTaskFile.test.ts b/packages/core/src/skills/bundled/loop/loopTaskFile.test.ts new file mode 100644 index 00000000000..5a841dd556d --- /dev/null +++ b/packages/core/src/skills/bundled/loop/loopTaskFile.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile } from './loopTaskFile.js'; + +describe('readLoopTaskFile', () => { + let tempDir: string; + let projectRoot: string; + let homeDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-task-file-')); + projectRoot = path.join(tempDir, 'project'); + homeDir = path.join(tempDir, 'home'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(homeDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + const writeProject = (content: string) => + fs + .mkdir(path.join(projectRoot, '.qwen'), { recursive: true }) + .then(() => + fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), content), + ); + const writeHome = (content: string) => + fs + .mkdir(path.join(homeDir, '.qwen'), { recursive: true }) + .then(() => + fs.writeFile(path.join(homeDir, '.qwen', 'loop.md'), content), + ); + + it('reads the project loop task file first', async () => { + await writeProject('project tasks'); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(projectRoot, '.qwen', 'loop.md'), + content: 'project tasks', + truncated: false, + }); + }); + + it('falls back to the user loop task file', async () => { + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('does not follow symlinked project loop task files', async () => { + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + const outside = path.join(tempDir, 'secret.txt'); + await fs.writeFile(outside, 'secret tasks'); + await fs.symlink(outside, path.join(projectRoot, '.qwen', 'loop.md')); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('refuses a project loop.md whose .qwen ancestor symlinks outside the workspace', async () => { + // `.qwen -> ` makes a final-component lstat pass while the file + // resolves outside the project; realpath must catch the ancestor symlink. + const outside = path.join(tempDir, 'outside'); + await fs.mkdir(outside, { recursive: true }); + await fs.writeFile(path.join(outside, 'loop.md'), 'escaped tasks'); + await fs.symlink(outside, path.join(projectRoot, '.qwen')); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('skips a non-directory component at .qwen (ENOTDIR) and falls through', async () => { + // A regular file where the `.qwen` dir should be → reading .qwen/loop.md + // raises ENOTDIR; skip to home rather than throwing. + await fs.writeFile(path.join(projectRoot, '.qwen'), 'not a dir'); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('skips a directory at the loop.md path and falls through', async () => { + // A directory at the project path yields EISDIR on read — skip it, not throw. + await fs.mkdir(path.join(projectRoot, '.qwen', 'loop.md'), { + recursive: true, + }); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('skips an empty or whitespace-only file and falls through', async () => { + await writeProject(' \n\t \n'); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + content: 'user tasks', + truncated: false, + }); + }); + + it('returns missing when every candidate is empty', async () => { + await writeProject(''); + await writeHome('\n \n'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [ + path.join(projectRoot, '.qwen', 'loop.md'), + path.join(homeDir, '.qwen', 'loop.md'), + ], + }); + }); + + it('returns a missing result when no task file exists', async () => { + await expect(readLoopTaskFile({ projectRoot, homeDir })).resolves.toEqual({ + status: 'missing', + checkedPaths: [ + path.join(projectRoot, '.qwen', 'loop.md'), + path.join(homeDir, '.qwen', 'loop.md'), + ], + }); + }); + + it('byte-caps task files above the cap and flags them truncated', async () => { + await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES + 5)); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.truncated).toBe(true); + } + }); + + it('does not truncate task files at exactly the byte cap', async () => { + await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES)); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.truncated).toBe(false); + } + }); + + it('truncates on a UTF-8 boundary without exceeding the cap or inserting a replacement char', async () => { + // 3-byte chars make the raw byte cap land mid-character. + await writeProject('一'.repeat(LOOP_TASK_FILE_MAX_BYTES)); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.content).not.toContain('�'); + } + }); +}); diff --git a/packages/core/src/skills/bundled/loop/loopTaskFile.ts b/packages/core/src/skills/bundled/loop/loopTaskFile.ts new file mode 100644 index 00000000000..ea7d4cabbd3 --- /dev/null +++ b/packages/core/src/skills/bundled/loop/loopTaskFile.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export const LOOP_TASK_FILE_MAX_BYTES = 25_000; + +export type LoopTaskFileResult = + | { + status: 'found'; + path: string; + content: string; + truncated: boolean; + } + | { + status: 'missing'; + checkedPaths: string[]; + }; + +export interface ReadLoopTaskFileOptions { + projectRoot: string; + homeDir: string; +} + +/** + * Reads `.qwen/loop.md`, project before home, byte-capped at 25 KB. A missing, + * directory, non-directory-component, or empty (whitespace-only) path is skipped + * to the next candidate rather than treated as present; all candidates exhausted + * → missing. Only the byte cap lives here — the fire-time resolver owns the + * user-facing truncation notice so the byte-vs-line nuance stays in one place. + * + * The project candidate is workspace-confined: its canonical real path must stay + * inside the project root. `fs.realpath` resolves `..` and every symlink — + * including an *ancestor* like a checked-in `.qwen -> /outside`, which a + * final-component `lstat` cannot catch — so a project loop.md cannot read a file + * outside the workspace. The home candidate is the user's own and intentionally + * outside the workspace, so it only refuses a directly-symlinked file. + */ +export async function readLoopTaskFile({ + projectRoot, + homeDir, +}: ReadLoopTaskFileOptions): Promise { + const projectFile = path.join(projectRoot, '.qwen', 'loop.md'); + const homeFile = path.join(homeDir, '.qwen', 'loop.md'); + const checkedPaths = [projectFile, homeFile]; + + for (const filePath of checkedPaths) { + let buffer: Buffer; + try { + if (filePath === projectFile) { + const realRoot = await fs.realpath(projectRoot); + const real = await fs.realpath(filePath); + if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { + continue; // escapes the workspace via a symlink → skip + } + buffer = await fs.readFile(real); + } else { + // lstat (not stat) so a directly symlinked home loop.md is detected + // rather than followed. + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) { + continue; + } + buffer = await fs.readFile(filePath); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // Absent (ENOENT), a directory (EISDIR), or a non-directory path component + // (ENOTDIR, e.g. a stray file where `.qwen` should be) → try the next + // candidate. Anything else (permissions, I/O) is a real error and surfaces. + if (code === 'ENOENT' || code === 'EISDIR' || code === 'ENOTDIR') { + continue; + } + throw error; + } + + // A whitespace-only file is not a task list; fall through to the next path. + if (buffer.toString('utf8').trim().length === 0) { + continue; + } + + const truncated = buffer.byteLength > LOOP_TASK_FILE_MAX_BYTES; + let content: string; + if (truncated) { + // Cap by bytes on a UTF-8 boundary: back off any trailing continuation + // bytes from a mid-character cut, then re-clamp the decoded string so + // malformed input (an orphan lead byte decoding to U+FFFD) still can't + // exceed the cap. + let end = LOOP_TASK_FILE_MAX_BYTES; + while (end > 0 && (buffer[end] & 0xc0) === 0x80) { + end--; + } + content = buffer.subarray(0, end).toString('utf8'); + while (Buffer.byteLength(content, 'utf8') > LOOP_TASK_FILE_MAX_BYTES) { + content = content.slice(0, -1); + } + } else { + content = buffer.toString('utf8'); + } + + return { + status: 'found', + path: filePath, + content, + truncated, + }; + } + + return { + status: 'missing', + checkedPaths, + }; +} diff --git a/packages/core/src/skills/bundled/loop/loopTickResolver.test.ts b/packages/core/src/skills/bundled/loop/loopTickResolver.test.ts new file mode 100644 index 00000000000..93fbf3e81b6 --- /dev/null +++ b/packages/core/src/skills/bundled/loop/loopTickResolver.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + LOOP_SENTINEL_CRON, + LOOP_SENTINEL_DYNAMIC, + LoopTickResolver, + detectLoopSentinel, +} from './loopTickResolver.js'; +import { LOOP_TASK_FILE_MAX_BYTES } from './loopTaskFile.js'; + +describe('detectLoopSentinel', () => { + it('recognizes the cron and dynamic sentinels exactly (after trim)', () => { + expect(detectLoopSentinel(LOOP_SENTINEL_CRON)).toBe('cron'); + expect(detectLoopSentinel(LOOP_SENTINEL_DYNAMIC)).toBe('dynamic'); + expect(detectLoopSentinel(` ${LOOP_SENTINEL_DYNAMIC}\n`)).toBe('dynamic'); + }); + + it('returns null for non-sentinel prompts', () => { + expect(detectLoopSentinel('/loop check the deploy')).toBeNull(); + expect(detectLoopSentinel('<> and more')).toBeNull(); + expect(detectLoopSentinel('')).toBeNull(); + }); +}); + +describe('LoopTickResolver', () => { + let tempDir: string; + let projectRoot: string; + let homeDir: string; + let resolver: LoopTickResolver; + + const projectFile = () => path.join(projectRoot, '.qwen', 'loop.md'); + const homeFile = () => path.join(homeDir, '.qwen', 'loop.md'); + const writeProject = (content: string) => + fs + .mkdir(path.join(projectRoot, '.qwen'), { recursive: true }) + .then(() => fs.writeFile(projectFile(), content)); + const writeHome = (content: string) => + fs + .mkdir(path.join(homeDir, '.qwen'), { recursive: true }) + .then(() => fs.writeFile(homeFile(), content)); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-tick-')); + projectRoot = path.join(tempDir, 'project'); + homeDir = path.join(tempDir, 'home'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(homeDir, { recursive: true }); + resolver = new LoopTickResolver({ projectRoot, homeDir }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('delivers the full task block on first fire', async () => { + await writeProject('- ship the thing'); + + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(true); + expect(tick.sourcePath).toBe(projectFile()); + expect(tick.modelText).toContain( + `# /loop tick — tasks from ${projectFile()}`, + ); + expect(tick.modelText).toContain('The user configured a loop-tasks file.'); + expect(tick.modelText).toContain('- ship the thing'); + // The full block ends with the same short reminder an unchanged fire emits. + expect(tick.modelText).toContain('(dynamic pacing)'); + }); + + it('delivers only the short reminder when content is unchanged', async () => { + await writeProject('- ship the thing'); + await resolver.resolve('dynamic'); + resolver.markDelivered(); + + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(false); + expect(tick.modelText).not.toContain( + 'The user configured a loop-tasks file.', + ); + expect(tick.modelText).toContain( + '# /loop tick — loop.md tasks (dynamic pacing)', + ); + }); + + it('commits content only on markDelivered, so an undelivered tick re-expands', async () => { + await writeProject('- tasks'); + expect((await resolver.resolve('dynamic')).full).toBe(true); + + // No markDelivered() — the block was never delivered (e.g. the tick was + // aborted before the send). The next tick must re-deliver the full block. + expect((await resolver.resolve('dynamic')).full).toBe(true); + + resolver.markDelivered(); + expect((await resolver.resolve('dynamic')).full).toBe(false); + }); + + it('re-delivers the full block when loop.md is edited', async () => { + await writeProject('- v1'); + await resolver.resolve('dynamic'); + resolver.markDelivered(); + + await writeProject('- v2 edited'); + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(true); + expect(tick.modelText).toContain('- v2 edited'); + }); + + it('re-delivers the full block after resetCache (compaction)', async () => { + await writeProject('- stable'); + await resolver.resolve('dynamic'); + resolver.markDelivered(); + expect((await resolver.resolve('dynamic')).full).toBe(false); + + resolver.resetCache(); + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(true); + expect(tick.modelText).toContain('- stable'); + }); + + it('emits the absent reminder without poisoning the cache, then re-expands on recreate', async () => { + const absent = await resolver.resolve('dynamic'); + expect(absent.full).toBe(false); + expect(absent.sourcePath).toBeUndefined(); + expect(absent.modelText).toContain('loop.md is not currently present'); + + await writeProject('- recreated tasks'); + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(true); + expect(tick.modelText).toContain('- recreated tasks'); + }); + + it('uses mode-specific reminders; dynamic names the re-arm sentinel', async () => { + await writeProject('- tasks'); + + const cron = await resolver.resolve('cron'); + expect(cron.modelText).toContain('do not call LoopWakeup from this tick'); + expect(cron.modelText).not.toContain('(dynamic pacing)'); + + // Fresh resolver so 'dynamic' is also a first (full) delivery. + const dyn = new LoopTickResolver({ projectRoot, homeDir }); + const dynTick = await dyn.resolve('dynamic'); + expect(dynTick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); + expect(dynTick.modelText).toContain('call LoopWakeup again'); + }); + + it('appends the truncation warning on a line boundary for oversized files', async () => { + const line = 'task line padding padding padding\n'; + const body = line.repeat(Math.ceil(LOOP_TASK_FILE_MAX_BYTES / line.length)); + await writeProject(body); + + const tick = await resolver.resolve('cron'); + + expect(tick.full).toBe(true); + const warning = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; + expect(tick.modelText).toContain(`\n${warning}`); + // The body is trimmed back to a COMPLETE line — the warning never glues onto + // a half-line. Guards against cutToLastNewline regressing to a no-op (which + // would leave the body ending mid-line, e.g. "task line "). + const beforeWarning = tick.modelText.slice( + 0, + tick.modelText.indexOf(`\n${warning}`), + ); + expect(beforeWarning.endsWith('task line padding padding padding')).toBe( + true, + ); + }); + + it('names the home loop.md in the header and re-expands when the source switches', async () => { + await writeProject('- project tasks'); + const first = await resolver.resolve('cron'); + resolver.markDelivered(); + expect(first.full).toBe(true); + expect(first.sourcePath).toBe(projectFile()); + + // Project gone, home has DIFFERENT content → re-expand (cache keys on + // content, not path) and the header now names the home file. + await fs.rm(projectFile()); + await writeHome('- home tasks'); + const second = await resolver.resolve('cron'); + + expect(second.full).toBe(true); + expect(second.sourcePath).toBe(homeFile()); + expect(second.modelText).toContain( + `# /loop tick — tasks from ${homeFile()}`, + ); + expect(second.modelText).toContain('- home tasks'); + }); +}); diff --git a/packages/core/src/skills/bundled/loop/loopTickResolver.ts b/packages/core/src/skills/bundled/loop/loopTickResolver.ts new file mode 100644 index 00000000000..90e73ff25ca --- /dev/null +++ b/packages/core/src/skills/bundled/loop/loopTickResolver.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile } from './loopTaskFile.js'; + +/** + * Fire-time resolver for `.qwen/loop.md`-driven loops. + * + * A `/loop` whose scheduled prompt is one of these sentinels re-reads loop.md + * on every fire and gets either the FULL task block (first delivery, or whenever + * the file changed) or a one-line SHORT reminder (unchanged) — so the task list + * is paid for once into the cached message-prefix and later ticks stay cheap. + * + * Divergence from the upstream design this mirrors: the `lastContent` cache is + * held per Session instance (not a module singleton) so it scopes to one + * conversation and resets cleanly with that conversation's context (compaction). + * Change-detection is full content equality, not mtime/hash, so edit and + * delete→recreate both re-expand for free. + */ + +export const LOOP_SENTINEL_CRON = '<>'; +export const LOOP_SENTINEL_DYNAMIC = '<>'; + +export type LoopMode = 'cron' | 'dynamic'; + +export interface LoopTickResolverDeps { + /** Pass `config.getWorkingDir()` — loop.md is resolved against the cwd. */ + projectRoot: string; + homeDir: string; +} + +export interface LoopTickResult { + /** Text to deliver to the model in place of the sentinel prompt. */ + modelText: string; + /** True when the full task block was delivered (vs a short reminder). */ + full: boolean; + /** Resolved loop.md path, when present — for a clean user-facing label. */ + sourcePath?: string; +} + +const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; + +const INTRO = + 'The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).'; + +const SHORT_REMINDER: Record = { + cron: + '# /loop tick — loop.md tasks\n' + + 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', + dynamic: + '# /loop tick — loop.md tasks (dynamic pacing)\n' + + 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', +}; + +const SHORT_ABSENT: Record = { + cron: + '# /loop tick — loop.md absent\n' + + 'loop.md is not currently present at .qwen/loop.md. Treat this as a no-op tick; the recurring cron fires the next tick automatically.', + dynamic: + '# /loop tick — loop.md absent (dynamic pacing)\n' + + 'loop.md is not currently present at .qwen/loop.md. Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', +}; + +/** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ +export function detectLoopSentinel(prompt: string): LoopMode | null { + const trimmed = prompt.trim(); + if (trimmed === LOOP_SENTINEL_DYNAMIC) { + return 'dynamic'; + } + if (trimmed === LOOP_SENTINEL_CRON) { + return 'cron'; + } + return null; +} + +/** Trim a truncated body back to its last full line before the warning tail. */ +function cutToLastNewline(content: string): string { + const cut = content.lastIndexOf('\n'); + return cut > 0 ? content.slice(0, cut) : content; +} + +export class LoopTickResolver { + // What the model has actually received. Drives full-vs-reminder detection. + #lastContent: string | null = null; + // The most recent resolve()'s content, committed to #lastContent only once + // the caller confirms it reached the model (markDelivered) — so a tick that + // is aborted between resolve() and delivery can't poison the cache into + // sending a dangling short reminder next time. + #pendingContent: string | null = null; + + constructor(private readonly deps: LoopTickResolverDeps) {} + + /** Forget the delivered content so the next fire re-delivers the full block + * — called when the conversation is compacted (fresh context). */ + resetCache(): void { + this.#lastContent = null; + this.#pendingContent = null; + } + + /** Commit the last resolve()'s content once it has reached the model. */ + markDelivered(): void { + if (this.#pendingContent !== null) { + this.#lastContent = this.#pendingContent; + } + } + + async resolve(mode: LoopMode): Promise { + const result = await readLoopTaskFile({ + projectRoot: this.deps.projectRoot, + homeDir: this.deps.homeDir, + }); + + if (result.status === 'missing') { + // Nothing to deliver, so nothing to commit; leave #lastContent untouched + // so a later recreate still compares unequal and re-delivers full. + this.#pendingContent = null; + return { modelText: SHORT_ABSENT[mode], full: false }; + } + + const content = result.truncated + ? `${cutToLastNewline(result.content)}\n${TRUNCATION_WARNING}` + : result.content; + this.#pendingContent = content; + + if (this.#lastContent === content) { + return { + modelText: SHORT_REMINDER[mode], + full: false, + sourcePath: result.path, + }; + } + + return { + modelText: `# /loop tick — tasks from ${result.path}\n${INTRO}\n${content}\n${SHORT_REMINDER[mode]}`, + full: true, + sourcePath: result.path, + }; + } +} From 7b630fef2fb13387b8914e20613118a8569309be Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 15:06:33 +0800 Subject: [PATCH 02/31] fix(cli): remove unused loop mode import --- packages/cli/src/acp-integration/session/Session.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index e0600c5475e..0fcaf5eac42 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -29,7 +29,6 @@ import type { GoalTerminalEvent, ToolCallRequestInfo, ToolCallResponseInfo, - LoopMode, } from '@qwen-code/qwen-code-core'; import { AuthType, From f36a5c8693afef68e704ce76ac6ebd5b87f72cb0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 16:25:51 +0800 Subject: [PATCH 03/31] fix(loop): rename loop task-file/tick-resolver modules to kebab-case The repo's eslint.config.js enforces `check-file/filename-naming-convention` with `{ '**/*.ts': 'KEBAB_CASE' }` over `packages/core/src/**/*.ts`, so the camelCase module names (loopTaskFile.ts, loopTickResolver.ts and their tests) failed the lint gate and blocked merge. Rename them to kebab-case via git mv (history preserved) and update the relative import/export paths in the package barrel and the renamed files. Exported symbol identifiers are unchanged, so consumers importing from @qwen-code/qwen-code-core need no update. Co-Authored-By: Qwen-Coder --- packages/core/src/index.ts | 4 ++-- .../loop/{loopTaskFile.test.ts => loop-task-file.test.ts} | 5 ++++- .../bundled/loop/{loopTaskFile.ts => loop-task-file.ts} | 0 .../{loopTickResolver.test.ts => loop-tick-resolver.test.ts} | 4 ++-- .../loop/{loopTickResolver.ts => loop-tick-resolver.ts} | 5 ++++- 5 files changed, 12 insertions(+), 6 deletions(-) rename packages/core/src/skills/bundled/loop/{loopTaskFile.test.ts => loop-task-file.test.ts} (98%) rename packages/core/src/skills/bundled/loop/{loopTaskFile.ts => loop-task-file.ts} (100%) rename packages/core/src/skills/bundled/loop/{loopTickResolver.test.ts => loop-tick-resolver.test.ts} (98%) rename packages/core/src/skills/bundled/loop/{loopTickResolver.ts => loop-tick-resolver.ts} (98%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a15584e4872..e07ba996323 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,8 +186,8 @@ export { export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export type { DurableCronTask } from './services/cronTasksFile.js'; -export * from './skills/bundled/loop/loopTaskFile.js'; -export * from './skills/bundled/loop/loopTickResolver.js'; +export * from './skills/bundled/loop/loop-task-file.js'; +export * from './skills/bundled/loop/loop-tick-resolver.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; diff --git a/packages/core/src/skills/bundled/loop/loopTaskFile.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts similarity index 98% rename from packages/core/src/skills/bundled/loop/loopTaskFile.test.ts rename to packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 5a841dd556d..dc05501c37c 100644 --- a/packages/core/src/skills/bundled/loop/loopTaskFile.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -8,7 +8,10 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile } from './loopTaskFile.js'; +import { + LOOP_TASK_FILE_MAX_BYTES, + readLoopTaskFile, +} from './loop-task-file.js'; describe('readLoopTaskFile', () => { let tempDir: string; diff --git a/packages/core/src/skills/bundled/loop/loopTaskFile.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts similarity index 100% rename from packages/core/src/skills/bundled/loop/loopTaskFile.ts rename to packages/core/src/skills/bundled/loop/loop-task-file.ts diff --git a/packages/core/src/skills/bundled/loop/loopTickResolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts similarity index 98% rename from packages/core/src/skills/bundled/loop/loopTickResolver.test.ts rename to packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 93fbf3e81b6..c8e2240d98d 100644 --- a/packages/core/src/skills/bundled/loop/loopTickResolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -13,8 +13,8 @@ import { LOOP_SENTINEL_DYNAMIC, LoopTickResolver, detectLoopSentinel, -} from './loopTickResolver.js'; -import { LOOP_TASK_FILE_MAX_BYTES } from './loopTaskFile.js'; +} from './loop-tick-resolver.js'; +import { LOOP_TASK_FILE_MAX_BYTES } from './loop-task-file.js'; describe('detectLoopSentinel', () => { it('recognizes the cron and dynamic sentinels exactly (after trim)', () => { diff --git a/packages/core/src/skills/bundled/loop/loopTickResolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts similarity index 98% rename from packages/core/src/skills/bundled/loop/loopTickResolver.ts rename to packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 90e73ff25ca..5bbbbd49466 100644 --- a/packages/core/src/skills/bundled/loop/loopTickResolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile } from './loopTaskFile.js'; +import { + LOOP_TASK_FILE_MAX_BYTES, + readLoopTaskFile, +} from './loop-task-file.js'; /** * Fire-time resolver for `.qwen/loop.md`-driven loops. From 9b70f168830ce046cb706b1300e82a6dc6c72eea Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 17:50:22 +0800 Subject: [PATCH 04/31] fix(loop): address re-review suggestions on loop.md injection - loop-task-file: log a debug trail when a project loop.md is skipped for escaping the workspace via a symlink (control flow unchanged). - loop-task-file.test: cover the non-whitelisted fs-error rethrow (EACCES) by mocking readFile so the assertion stays cross-platform stable. - loop-tick-resolver: SHORT_ABSENT now names both the project (.qwen/loop.md) and home (~/.qwen/loop.md) candidates that are checked. - loop-tick-resolver.test: assert tick.sourcePath on the unchanged branch. - index: move the two loop barrel exports from Services into the Skills section to restore grouping/ordering (consumers import via the barrel). Co-Authored-By: Qwen-Coder --- packages/core/src/index.ts | 4 ++-- .../bundled/loop/loop-task-file.test.ts | 24 ++++++++++++++++++- .../src/skills/bundled/loop/loop-task-file.ts | 14 ++++++++++- .../bundled/loop/loop-tick-resolver.test.ts | 3 +++ .../skills/bundled/loop/loop-tick-resolver.ts | 4 ++-- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e07ba996323..f462787c2bd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,8 +186,6 @@ export { export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export type { DurableCronTask } from './services/cronTasksFile.js'; -export * from './skills/bundled/loop/loop-task-file.js'; -export * from './skills/bundled/loop/loop-tick-resolver.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; @@ -365,6 +363,8 @@ export { export * from './extension/index.js'; export * from './prompts/mcp-prompts.js'; export * from './skills/index.js'; +export * from './skills/bundled/loop/loop-task-file.js'; +export * from './skills/bundled/loop/loop-tick-resolver.js'; export * from './subagents/index.js'; export * from './agents/index.js'; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index dc05501c37c..bf443e1f947 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -7,12 +7,19 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, } from './loop-task-file.js'; +// Make only readFile controllable; every other fs call stays real so the +// temp-dir fixtures keep working. The default impl calls through to actual. +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual(); + return { ...actual, readFile: vi.fn(actual.readFile) }; +}); + describe('readLoopTaskFile', () => { let tempDir: string; let projectRoot: string; @@ -27,6 +34,7 @@ describe('readLoopTaskFile', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -139,6 +147,20 @@ describe('readLoopTaskFile', () => { }); }); + it('rethrows non-whitelisted fs errors (e.g. EACCES)', async () => { + // Only ENOENT/EISDIR/ENOTDIR fall through to the next candidate; a real + // error such as a permission denial must surface, not be swallowed. + await writeProject('project tasks'); + const eacces = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + vi.mocked(fs.readFile).mockRejectedValueOnce(eacces); + + await expect(readLoopTaskFile({ projectRoot, homeDir })).rejects.toThrow( + /EACCES/, + ); + }); + it('skips an empty or whitespace-only file and falls through', async () => { await writeProject(' \n\t \n'); await writeHome('user tasks'); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index ea7d4cabbd3..1928cc905b2 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -6,6 +6,9 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { createDebugLogger } from '../../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LOOP_TASK_FILE'); export const LOOP_TASK_FILE_MAX_BYTES = 25_000; @@ -55,7 +58,16 @@ export async function readLoopTaskFile({ const realRoot = await fs.realpath(projectRoot); const real = await fs.realpath(filePath); if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { - continue; // escapes the workspace via a symlink → skip + // Skip silently to the next candidate, but leave a debug trail so a + // symlink quietly redirecting loop.md outside the workspace is traceable. + debugLogger.debug( + 'skipping project loop.md that escapes the workspace', + { + filePath, + resolved: real, + }, + ); + continue; } buffer = await fs.readFile(real); } else { diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index c8e2240d98d..c6fce63d272 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -84,6 +84,9 @@ describe('LoopTickResolver', () => { const tick = await resolver.resolve('dynamic'); expect(tick.full).toBe(false); + // The unchanged branch still reports the resolved source so Session.ts can + // label it even when only the short reminder is sent. + expect(tick.sourcePath).toBe(projectFile()); expect(tick.modelText).not.toContain( 'The user configured a loop-tasks file.', ); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 5bbbbd49466..520378dbc6c 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -61,10 +61,10 @@ const SHORT_REMINDER: Record = { const SHORT_ABSENT: Record = { cron: '# /loop tick — loop.md absent\n' + - 'loop.md is not currently present at .qwen/loop.md. Treat this as a no-op tick; the recurring cron fires the next tick automatically.', + 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick; the recurring cron fires the next tick automatically.', dynamic: '# /loop tick — loop.md absent (dynamic pacing)\n' + - 'loop.md is not currently present at .qwen/loop.md. Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', + 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', }; /** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ From 91759a8fc4f8437139335778798c2f0bd6d7593c Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 19:40:53 +0800 Subject: [PATCH 05/31] =?UTF-8?q?fix(loop):=20address=20loop.md=20re-revie?= =?UTF-8?q?w=20=E2=80=94=20bounded=20read,=20cached=20realpath,=20single?= =?UTF-8?q?=20H1,=20reset=20cache=20on=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read at most LOOP_TASK_FILE_MAX_BYTES+1 via fs.open so a huge/malicious .qwen/loop.md is no longer fully read+decoded each tick (DoS hardening) - cache the project-root realpath once per resolver instead of per tick - emit a single H1 and avoid leaking the absolute path into the model prompt - clear lastContent on the absent path so delete/recreate re-expands the block Co-Authored-By: Qwen-Coder --- .../bundled/loop/loop-task-file.test.ts | 29 ++++++++-- .../src/skills/bundled/loop/loop-task-file.ts | 55 +++++++++++++++++-- .../bundled/loop/loop-tick-resolver.test.ts | 31 ++++++++++- .../skills/bundled/loop/loop-tick-resolver.ts | 54 +++++++++++++++--- 4 files changed, 150 insertions(+), 19 deletions(-) diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index bf443e1f947..084945b6d9d 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -13,11 +13,12 @@ import { readLoopTaskFile, } from './loop-task-file.js'; -// Make only readFile controllable; every other fs call stays real so the -// temp-dir fixtures keep working. The default impl calls through to actual. +// Make only open controllable; every other fs call stays real so the temp-dir +// fixtures keep working. The reader is bounded via fs.open + filehandle.read, +// so open is the injection point. The default impl calls through to actual. vi.mock('node:fs/promises', async (importActual) => { const actual = await importActual(); - return { ...actual, readFile: vi.fn(actual.readFile) }; + return { ...actual, open: vi.fn(actual.open) }; }); describe('readLoopTaskFile', () => { @@ -154,7 +155,7 @@ describe('readLoopTaskFile', () => { const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', }); - vi.mocked(fs.readFile).mockRejectedValueOnce(eacces); + vi.mocked(fs.open).mockRejectedValueOnce(eacces); await expect(readLoopTaskFile({ projectRoot, homeDir })).rejects.toThrow( /EACCES/, @@ -214,6 +215,26 @@ describe('readLoopTaskFile', () => { } }); + it('bounds the read for a very large file (reads at most cap + 1 bytes)', async () => { + // A multi-MB file must not be fully read/decoded every tick: the bounded + // reader caps the buffer, so content stays at the cap and truncated flips. + await writeProject('x'.repeat(2_000_000)); + const openSpy = vi.mocked(fs.open); + openSpy.mockClear(); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); + } + // Read through a single bounded fs.open handle, not fs.readFile of the whole. + expect(openSpy).toHaveBeenCalledTimes(1); + }); + it('does not truncate task files at exactly the byte cap', async () => { await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES)); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 1928cc905b2..f1e0d749efa 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -27,6 +27,47 @@ export type LoopTaskFileResult = export interface ReadLoopTaskFileOptions { projectRoot: string; homeDir: string; + /** + * Pre-resolved `fs.realpath(projectRoot)`. projectRoot is stable for a + * resolver's lifetime, so the resolver resolves it once and passes it here + * to avoid a realpath syscall every tick. Omit to resolve inline. + */ + realProjectRoot?: string; +} + +/** + * Read at most `LOOP_TASK_FILE_MAX_BYTES + 1` bytes — the one extra byte is the + * truncation signal and the only thing we need past the cap, so a huge/malicious + * loop.md is never fully read or decoded. Returns `null` for a non-regular node + * (e.g. a directory at the loop.md path) so the caller skips to the next + * candidate. Symlink/escape filtering is the caller's job and already done. + */ +async function readBoundedTaskFile(filePath: string): Promise { + const handle = await fs.open(filePath, 'r'); + try { + if (!(await handle.stat()).isFile()) { + return null; + } + const cap = LOOP_TASK_FILE_MAX_BYTES + 1; + const buffer = Buffer.alloc(cap); + let total = 0; + // A single read() may return short even before EOF; loop until cap or EOF. + while (total < cap) { + const { bytesRead } = await handle.read( + buffer, + total, + cap - total, + total, + ); + if (bytesRead === 0) { + break; + } + total += bytesRead; + } + return buffer.subarray(0, total); + } finally { + await handle.close(); + } } /** @@ -46,16 +87,17 @@ export interface ReadLoopTaskFileOptions { export async function readLoopTaskFile({ projectRoot, homeDir, + realProjectRoot, }: ReadLoopTaskFileOptions): Promise { const projectFile = path.join(projectRoot, '.qwen', 'loop.md'); const homeFile = path.join(homeDir, '.qwen', 'loop.md'); const checkedPaths = [projectFile, homeFile]; for (const filePath of checkedPaths) { - let buffer: Buffer; + let buffer: Buffer | null; try { if (filePath === projectFile) { - const realRoot = await fs.realpath(projectRoot); + const realRoot = realProjectRoot ?? (await fs.realpath(projectRoot)); const real = await fs.realpath(filePath); if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { // Skip silently to the next candidate, but leave a debug trail so a @@ -69,7 +111,7 @@ export async function readLoopTaskFile({ ); continue; } - buffer = await fs.readFile(real); + buffer = await readBoundedTaskFile(real); } else { // lstat (not stat) so a directly symlinked home loop.md is detected // rather than followed. @@ -77,7 +119,7 @@ export async function readLoopTaskFile({ if (stat.isSymbolicLink()) { continue; } - buffer = await fs.readFile(filePath); + buffer = await readBoundedTaskFile(filePath); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -90,6 +132,11 @@ export async function readLoopTaskFile({ throw error; } + // A non-regular node (e.g. a directory where loop.md was expected) → skip. + if (buffer === null) { + continue; + } + // A whitespace-only file is not a task list; fall through to the next path. if (buffer.toString('utf8').trim().length === 0) { continue; diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index c6fce63d272..8c2dcca62ff 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -66,14 +66,18 @@ describe('LoopTickResolver', () => { const tick = await resolver.resolve('dynamic'); expect(tick.full).toBe(true); + // sourcePath keeps the absolute path for local UI; the model text must not. expect(tick.sourcePath).toBe(projectFile()); expect(tick.modelText).toContain( - `# /loop tick — tasks from ${projectFile()}`, + '# /loop tick — loop.md tasks from project loop.md', ); + expect(tick.modelText).not.toContain(projectFile()); expect(tick.modelText).toContain('The user configured a loop-tasks file.'); expect(tick.modelText).toContain('- ship the thing'); // The full block ends with the same short reminder an unchanged fire emits. expect(tick.modelText).toContain('(dynamic pacing)'); + // Exactly one H1 in the whole message (no duplicated tick heading). + expect(tick.modelText.match(/^# /gm)).toHaveLength(1); }); it('delivers only the short reminder when content is unchanged', async () => { @@ -145,6 +149,27 @@ describe('LoopTickResolver', () => { expect(tick.modelText).toContain('- recreated tasks'); }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { + await writeProject('- same tasks'); + expect((await resolver.resolve('dynamic')).full).toBe(true); + resolver.markDelivered(); + // Unchanged content → short reminder, as expected. + expect((await resolver.resolve('dynamic')).full).toBe(false); + + // Delete → the absent tick clears the delivered-content memory. + await fs.rm(projectFile()); + const absent = await resolver.resolve('dynamic'); + expect(absent.full).toBe(false); + expect(absent.modelText).toContain('loop.md is not currently present'); + + // Recreate with byte-identical content. Absence was a state change, so the + // full block must re-expand rather than collapse to a dangling reminder. + await writeProject('- same tasks'); + const tick = await resolver.resolve('dynamic'); + expect(tick.full).toBe(true); + expect(tick.modelText).toContain('- same tasks'); + }); + it('uses mode-specific reminders; dynamic names the re-arm sentinel', async () => { await writeProject('- tasks'); @@ -197,8 +222,10 @@ describe('LoopTickResolver', () => { expect(second.full).toBe(true); expect(second.sourcePath).toBe(homeFile()); expect(second.modelText).toContain( - `# /loop tick — tasks from ${homeFile()}`, + '# /loop tick — loop.md tasks from home loop.md', ); + // The absolute home path must not leak into the model-facing text. + expect(second.modelText).not.toContain(homeFile()); expect(second.modelText).toContain('- home tasks'); }); }); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 520378dbc6c..1c0770ff264 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, @@ -49,15 +51,26 @@ const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE const INTRO = 'The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).'; -const SHORT_REMINDER: Record = { - cron: - '# /loop tick — loop.md tasks\n' + - 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', +// Body of the unchanged-tick reminder — the H1 is supplied by tickHeading() so +// the full block and the short reminder share exactly one heading style. +const SHORT_REMINDER_BODY: Record = { + cron: 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', dynamic: - '# /loop tick — loop.md tasks (dynamic pacing)\n' + 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', }; +/** + * The single H1 for a tick message. `sourceLabel` (set only on a full-block + * delivery) is a relative label like "project loop.md", never the absolute + * path — so the resolved file location isn't leaked to the model/API provider. + */ +function tickHeading(mode: LoopMode, sourceLabel?: string): string { + const base = sourceLabel + ? `# /loop tick — loop.md tasks from ${sourceLabel}` + : '# /loop tick — loop.md tasks'; + return mode === 'dynamic' ? `${base} (dynamic pacing)` : base; +} + const SHORT_ABSENT: Record = { cron: '# /loop tick — loop.md absent\n' + @@ -94,8 +107,21 @@ export class LoopTickResolver { // sending a dangling short reminder next time. #pendingContent: string | null = null; + // fs.realpath(projectRoot) is stable for this resolver's lifetime (projectRoot + // only changes on /cd, which rebuilds the resolver), so resolve it once and + // reuse. On failure resolve to undefined → readLoopTaskFile recomputes inline + // and surfaces the real error, preserving per-tick error semantics. + #realProjectRoot: Promise | undefined; + constructor(private readonly deps: LoopTickResolverDeps) {} + #getRealProjectRoot(): Promise { + this.#realProjectRoot ??= fs + .realpath(this.deps.projectRoot) + .catch(() => undefined); + return this.#realProjectRoot; + } + /** Forget the delivered content so the next fire re-delivers the full block * — called when the conversation is compacted (fresh context). */ resetCache(): void { @@ -114,12 +140,16 @@ export class LoopTickResolver { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, + realProjectRoot: await this.#getRealProjectRoot(), }); if (result.status === 'missing') { - // Nothing to deliver, so nothing to commit; leave #lastContent untouched - // so a later recreate still compares unequal and re-delivers full. + // Absence is itself a state change: clear both caches so a later recreate + // — even with byte-identical content — re-expands the full block rather + // than sending a dangling short reminder that points at a block no longer + // guaranteed to be in context. this.#pendingContent = null; + this.#lastContent = null; return { modelText: SHORT_ABSENT[mode], full: false }; } @@ -130,14 +160,20 @@ export class LoopTickResolver { if (this.#lastContent === content) { return { - modelText: SHORT_REMINDER[mode], + modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_BODY[mode]}`, full: false, sourcePath: result.path, }; } + // Relative label, not result.path (the absolute path) — that would leak the + // OS username / dir layout to the API provider. The absolute path still goes + // to the caller via sourcePath for local UI use. + const projectFile = path.join(this.deps.projectRoot, '.qwen', 'loop.md'); + const sourceLabel = + result.path === projectFile ? 'project loop.md' : 'home loop.md'; return { - modelText: `# /loop tick — tasks from ${result.path}\n${INTRO}\n${content}\n${SHORT_REMINDER[mode]}`, + modelText: `${tickHeading(mode, sourceLabel)}\n${INTRO}\n${content}\n${SHORT_REMINDER_BODY[mode]}`, full: true, sourcePath: result.path, }; From a6da917c3e320df8cdf2ae2a2a1874d45e0b236c Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 26 Jun 2026 20:22:32 +0800 Subject: [PATCH 06/31] =?UTF-8?q?fix(loop):=20address=20re-review=20?= =?UTF-8?q?=E2=80=94=20heading=20test,=20absent=20heading,=20source=20labe?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session.test.ts: fix the stale assertion to the current "# /loop tick — loop.md tasks from" heading (was the pre-rename "# /loop tick — tasks from") — the likely CI failure. - loop-tick-resolver: route the absent case through tickHeading() so every tick variant (full block, short reminder, absent) shares one heading and the dynamic-pacing suffix; emitted text is unchanged. - loop-task-file: return a semantic `source` ('project' | 'home') for a found loop.md; the resolver maps it to a label via an exhaustive Record, so a future candidate can't silently mislabel as "home loop.md". Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 2 +- .../bundled/loop/loop-task-file.test.ts | 7 +++ .../src/skills/bundled/loop/loop-task-file.ts | 22 +++++-- .../bundled/loop/loop-tick-resolver.test.ts | 13 +++++ .../skills/bundled/loop/loop-tick-resolver.ts | 58 ++++++++++++------- 5 files changed, 74 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index b32eaf676ff..20b2d586ab6 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4326,7 +4326,7 @@ describe('Session', () => { .map((p) => p.text ?? '') .join(''); }); - expect(block).toContain('# /loop tick — tasks from'); + expect(block).toContain('# /loop tick — loop.md tasks from'); expect(block).toContain('- finish the migration'); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 084945b6d9d..fb42e260b20 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -61,6 +61,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(projectRoot, '.qwen', 'loop.md'), + source: 'project', content: 'project tasks', truncated: false, }); @@ -74,6 +75,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); @@ -91,6 +93,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); @@ -110,6 +113,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); @@ -126,6 +130,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); @@ -143,6 +148,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); @@ -171,6 +177,7 @@ describe('readLoopTaskFile', () => { expect(result).toEqual({ status: 'found', path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', content: 'user tasks', truncated: false, }); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index f1e0d749efa..031f3486531 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -12,10 +12,15 @@ const debugLogger = createDebugLogger('LOOP_TASK_FILE'); export const LOOP_TASK_FILE_MAX_BYTES = 25_000; +/** Which candidate a found loop.md came from. The caller maps this to a label + * (an exhaustive map fails closed if a new candidate is added). */ +export type LoopTaskFileSource = 'project' | 'home'; + export type LoopTaskFileResult = | { status: 'found'; path: string; + source: LoopTaskFileSource; content: string; truncated: boolean; } @@ -89,14 +94,18 @@ export async function readLoopTaskFile({ homeDir, realProjectRoot, }: ReadLoopTaskFileOptions): Promise { - const projectFile = path.join(projectRoot, '.qwen', 'loop.md'); - const homeFile = path.join(homeDir, '.qwen', 'loop.md'); - const checkedPaths = [projectFile, homeFile]; + const candidates: ReadonlyArray<{ + source: LoopTaskFileSource; + path: string; + }> = [ + { source: 'project', path: path.join(projectRoot, '.qwen', 'loop.md') }, + { source: 'home', path: path.join(homeDir, '.qwen', 'loop.md') }, + ]; - for (const filePath of checkedPaths) { + for (const { source, path: filePath } of candidates) { let buffer: Buffer | null; try { - if (filePath === projectFile) { + if (source === 'project') { const realRoot = realProjectRoot ?? (await fs.realpath(projectRoot)); const real = await fs.realpath(filePath); if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { @@ -164,6 +173,7 @@ export async function readLoopTaskFile({ return { status: 'found', path: filePath, + source, content, truncated, }; @@ -171,6 +181,6 @@ export async function readLoopTaskFile({ return { status: 'missing', - checkedPaths, + checkedPaths: candidates.map((c) => c.path), }; } diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 8c2dcca62ff..d579a61426e 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -149,6 +149,19 @@ describe('LoopTickResolver', () => { expect(tick.modelText).toContain('- recreated tasks'); }); + it('gives the absent tick the same shared heading style (and dynamic suffix)', async () => { + const cron = await resolver.resolve('cron'); + expect(cron.modelText).toContain('# /loop tick — loop.md absent\n'); + + const dyn = new LoopTickResolver({ projectRoot, homeDir }); + const dynTick = await dyn.resolve('dynamic'); + expect(dynTick.modelText).toContain( + '# /loop tick — loop.md absent (dynamic pacing)\n', + ); + // Exactly one H1 — the heading isn't duplicated by the body. + expect(dynTick.modelText.match(/^# /gm)).toHaveLength(1); + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 1c0770ff264..0998037c475 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -5,10 +5,10 @@ */ import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, + type LoopTaskFileSource, } from './loop-task-file.js'; /** @@ -60,23 +60,38 @@ const SHORT_REMINDER_BODY: Record = { }; /** - * The single H1 for a tick message. `sourceLabel` (set only on a full-block - * delivery) is a relative label like "project loop.md", never the absolute - * path — so the resolved file location isn't leaked to the model/API provider. + * The single H1 for every tick variant (full block, short reminder, absent), so + * they share one heading style and the dynamic-pacing suffix lives in one place. + * `sourceLabel` (set only on a full-block delivery) is a relative label like + * "project loop.md", never the absolute path — so the resolved file location + * isn't leaked to the model/API provider. */ -function tickHeading(mode: LoopMode, sourceLabel?: string): string { - const base = sourceLabel - ? `# /loop tick — loop.md tasks from ${sourceLabel}` - : '# /loop tick — loop.md tasks'; +function tickHeading( + mode: LoopMode, + opts: { sourceLabel?: string; absent?: boolean } = {}, +): string { + const subject = opts.absent + ? 'loop.md absent' + : opts.sourceLabel + ? `loop.md tasks from ${opts.sourceLabel}` + : 'loop.md tasks'; + const base = `# /loop tick — ${subject}`; return mode === 'dynamic' ? `${base} (dynamic pacing)` : base; } -const SHORT_ABSENT: Record = { - cron: - '# /loop tick — loop.md absent\n' + - 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick; the recurring cron fires the next tick automatically.', +/** Model-safe relative label per source — exhaustive, so a new loop.md + * candidate added to readLoopTaskFile won't compile until it gets a label + * (rather than silently mislabelling it). */ +const SOURCE_LABELS: Record = { + project: 'project loop.md', + home: 'home loop.md', +}; + +// Body of the absent reminder — the H1 is supplied by tickHeading() so the +// absent tick shares the same heading style as the full block and reminder. +const SHORT_ABSENT_BODY: Record = { + cron: 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick; the recurring cron fires the next tick automatically.', dynamic: - '# /loop tick — loop.md absent (dynamic pacing)\n' + 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', }; @@ -150,7 +165,10 @@ export class LoopTickResolver { // guaranteed to be in context. this.#pendingContent = null; this.#lastContent = null; - return { modelText: SHORT_ABSENT[mode], full: false }; + return { + modelText: `${tickHeading(mode, { absent: true })}\n${SHORT_ABSENT_BODY[mode]}`, + full: false, + }; } const content = result.truncated @@ -166,14 +184,12 @@ export class LoopTickResolver { }; } - // Relative label, not result.path (the absolute path) — that would leak the - // OS username / dir layout to the API provider. The absolute path still goes - // to the caller via sourcePath for local UI use. - const projectFile = path.join(this.deps.projectRoot, '.qwen', 'loop.md'); - const sourceLabel = - result.path === projectFile ? 'project loop.md' : 'home loop.md'; + // Label by which candidate matched, not result.path (the absolute path) — + // the absolute path would leak the OS username / dir layout to the API + // provider. It still reaches the caller via sourcePath for local UI use. + const sourceLabel = SOURCE_LABELS[result.source]; return { - modelText: `${tickHeading(mode, sourceLabel)}\n${INTRO}\n${content}\n${SHORT_REMINDER_BODY[mode]}`, + modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${SHORT_REMINDER_BODY[mode]}`, full: true, sourcePath: result.path, }; From 6c7466d7e6b77ad23580d4638250703bda3d78c2 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 00:26:31 +0800 Subject: [PATCH 07/31] fix(loop): keep loop.md confinement root internal; harden bounded-read test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address re-review on the loop.md injection change: - loop-task-file: drop `realProjectRoot` from the exported ReadLoopTaskFileOptions. The module is re-exported from @qwen-code/qwen-code-core, so a caller could pass a stale/broader root and widen the workspace-confinement boundary for project loop.md. The real project root is now derived from the trusted `projectRoot` inside readLoopTaskFile and cached per-root (kept off the public API), so callers can't supply a different confinement root. The resolver drops its private realpath cache accordingly. - loop-task-file.test: make the bounded-read test load-bearing. It now wraps the file handle to observe read() calls and asserts no read — and their sum — exceeds the cap budget (LOOP_TASK_FILE_MAX_BYTES + 1), plus a short-file EOF case. It fails on a "read the whole file, then slice" regression that the previous content-only assertion let pass. Co-Authored-By: Qwen-Coder --- .../bundled/loop/loop-task-file.test.ts | 65 +++++++++++++++++-- .../src/skills/bundled/loop/loop-task-file.ts | 30 ++++++--- .../skills/bundled/loop/loop-tick-resolver.ts | 15 ----- 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index fb42e260b20..872c09b377d 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -52,6 +52,32 @@ describe('readLoopTaskFile', () => { fs.writeFile(path.join(homeDir, '.qwen', 'loop.md'), content), ); + // Wrap the next fs.open so each handle.read() length is recorded against a real + // handle. Lets a test prove the reader stays bounded: a "read the whole file, + // then slice" regression pulls the full file through these reads and trips the + // per-read / cumulative cap assertions. Returns the array, filled by reference. + const recordHandleReadLengths = async (): Promise => { + const lengths: number[] = []; + const actual = + await vi.importActual( + 'node:fs/promises', + ); + vi.mocked(fs.open).mockImplementationOnce(async (p) => { + const handle = await actual.open( + p as Parameters[0], + 'r', + ); + const realRead = handle.read.bind(handle); + handle.read = ((...readArgs: Parameters) => { + // Impl calls read(buffer, offset, length, position); record length. + lengths.push((readArgs as unknown[])[2] as number); + return realRead(...(readArgs as Parameters)); + }) as typeof handle.read; + return handle; + }); + return lengths; + }; + it('reads the project loop task file first', async () => { await writeProject('project tasks'); await writeHome('user tasks'); @@ -222,12 +248,16 @@ describe('readLoopTaskFile', () => { } }); - it('bounds the read for a very large file (reads at most cap + 1 bytes)', async () => { - // A multi-MB file must not be fully read/decoded every tick: the bounded - // reader caps the buffer, so content stays at the cap and truncated flips. + it('bounds the read for a very large file (never reads past the cap)', async () => { + // A multi-MB file must not be fully read/decoded every tick. Observe the + // actual handle.read() calls: neither any single read nor their sum may + // exceed the cap budget — so a "read the whole file, then slice" regression + // (which would pull all 2 MB through these reads) fails this test. await writeProject('x'.repeat(2_000_000)); + const cap = LOOP_TASK_FILE_MAX_BYTES + 1; const openSpy = vi.mocked(fs.open); openSpy.mockClear(); + const readLengths = await recordHandleReadLengths(); const result = await readLoopTaskFile({ projectRoot, homeDir }); @@ -238,8 +268,35 @@ describe('readLoopTaskFile', () => { LOOP_TASK_FILE_MAX_BYTES, ); } - // Read through a single bounded fs.open handle, not fs.readFile of the whole. + // A single bounded fs.open handle, not fs.readFile of the whole. expect(openSpy).toHaveBeenCalledTimes(1); + // Load-bearing: every read, and the total bytes requested, stay within cap. + expect(readLengths.length).toBeGreaterThan(0); + for (const length of readLengths) { + expect(length).toBeLessThanOrEqual(cap); + } + expect(readLengths.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(cap); + }); + + it('reads a short file fully via bounded reads that never exceed the cap', async () => { + // The EOF path: a sub-cap file is returned whole (not truncated), and the + // bounded reader still never requests past the cap on any read. + const body = 'short tasks\n'; + await writeProject(body); + const cap = LOOP_TASK_FILE_MAX_BYTES + 1; + const readLengths = await recordHandleReadLengths(); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toMatchObject({ + status: 'found', + content: body, + truncated: false, + }); + expect(readLengths.length).toBeGreaterThan(0); + for (const length of readLengths) { + expect(length).toBeLessThanOrEqual(cap); + } }); it('does not truncate task files at exactly the byte cap', async () => { diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 031f3486531..406b433c7a2 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -32,12 +32,27 @@ export type LoopTaskFileResult = export interface ReadLoopTaskFileOptions { projectRoot: string; homeDir: string; - /** - * Pre-resolved `fs.realpath(projectRoot)`. projectRoot is stable for a - * resolver's lifetime, so the resolver resolves it once and passes it here - * to avoid a realpath syscall every tick. Omit to resolve inline. - */ - realProjectRoot?: string; +} + +/** + * Canonical `fs.realpath(projectRoot)` cache — the workspace-confinement + * boundary for the project loop.md. It is stable for the process, so resolve it + * once per root instead of every tick. Keyed by the TRUSTED projectRoot and + * never accepted from a caller, so an external caller of this re-exported + * function can't widen the boundary with a stale/broader path. + */ +const realProjectRootCache = new Map>(); + +function resolveRealProjectRoot(projectRoot: string): Promise { + let real = realProjectRootCache.get(projectRoot); + if (real === undefined) { + real = fs.realpath(projectRoot); + // Don't pin a rejection: a transient failure (EACCES, ENOENT) must be + // retried next tick rather than cached, preserving per-tick error semantics. + real.catch(() => realProjectRootCache.delete(projectRoot)); + realProjectRootCache.set(projectRoot, real); + } + return real; } /** @@ -92,7 +107,6 @@ async function readBoundedTaskFile(filePath: string): Promise { export async function readLoopTaskFile({ projectRoot, homeDir, - realProjectRoot, }: ReadLoopTaskFileOptions): Promise { const candidates: ReadonlyArray<{ source: LoopTaskFileSource; @@ -106,7 +120,7 @@ export async function readLoopTaskFile({ let buffer: Buffer | null; try { if (source === 'project') { - const realRoot = realProjectRoot ?? (await fs.realpath(projectRoot)); + const realRoot = await resolveRealProjectRoot(projectRoot); const real = await fs.realpath(filePath); if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { // Skip silently to the next candidate, but leave a debug trail so a diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 0998037c475..5ccd514f0e9 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'node:fs/promises'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, @@ -122,21 +121,8 @@ export class LoopTickResolver { // sending a dangling short reminder next time. #pendingContent: string | null = null; - // fs.realpath(projectRoot) is stable for this resolver's lifetime (projectRoot - // only changes on /cd, which rebuilds the resolver), so resolve it once and - // reuse. On failure resolve to undefined → readLoopTaskFile recomputes inline - // and surfaces the real error, preserving per-tick error semantics. - #realProjectRoot: Promise | undefined; - constructor(private readonly deps: LoopTickResolverDeps) {} - #getRealProjectRoot(): Promise { - this.#realProjectRoot ??= fs - .realpath(this.deps.projectRoot) - .catch(() => undefined); - return this.#realProjectRoot; - } - /** Forget the delivered content so the next fire re-delivers the full block * — called when the conversation is compacted (fresh context). */ resetCache(): void { @@ -155,7 +141,6 @@ export class LoopTickResolver { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, - realProjectRoot: await this.#getRealProjectRoot(), }); if (result.status === 'missing') { From a278fd022df22e00d2a0dd45b7c27072a73eed34 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 01:46:55 +0800 Subject: [PATCH 08/31] test(core): cover loop.md project-root realpath cache eviction on transient failure Add a load-bearing test proving a transient fs.realpath(projectRoot) failure (EACCES/ENOENT) is not pinned in the per-process cache: the entry is evicted on rejection so the next tick re-resolves instead of replaying a permanently-cached rejection. Without that eviction, a single transient error would break loop.md resolution for that root forever. The test goes red if the `.catch(() => realProjectRootCache.delete(...))` eviction line is removed and green once restored; driven purely via the realpath mock, with no timing waits. Co-Authored-By: Qwen-Coder --- .../bundled/loop/loop-task-file.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 872c09b377d..e172bf7e05a 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -194,6 +194,47 @@ describe('readLoopTaskFile', () => { ); }); + it('evicts the cached project-root realpath after a transient failure and retries on the next tick', async () => { + // The project-root realpath is cached per process. A TRANSIENT failure + // (EACCES/ENOENT) must NOT be pinned: the entry is evicted on rejection so + // the next tick re-resolves instead of replaying a permanently-cached + // rejection. Drop that eviction and one transient error would break loop.md + // resolution for this root forever. Drive it purely via the realpath mock. + await writeProject('project tasks'); + + const eacces = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const realpathSpy = vi.spyOn(fs, 'realpath'); + // Fail the first project-root resolution, then resolve normally. + realpathSpy.mockRejectedValueOnce(eacces); + realpathSpy.mockImplementation((p) => actual.realpath(p as string)); + + // First tick: the transient error surfaces (current per-tick semantics). + await expect(readLoopTaskFile({ projectRoot, homeDir })).rejects.toThrow( + /EACCES/, + ); + + // Second tick: the poisoned entry was evicted, so realpath is retried and + // the project loop.md resolves — proving the rejection was not cached. + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(projectRoot, '.qwen', 'loop.md'), + source: 'project', + content: 'project tasks', + truncated: false, + }); + // The root was re-resolved on the retry (call #2), not served from a + // poisoned cache entry; #3 is the loop.md realpath on the successful tick. + expect(realpathSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + it('skips an empty or whitespace-only file and falls through', async () => { await writeProject(' \n\t \n'); await writeHome('user tasks'); From 89f5ff80eeb4a7a8b43a249644966c94fc5fbfa5 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 02:38:21 +0800 Subject: [PATCH 09/31] fix(loop): harden loop.md against symlink exfiltration, FIFO hang, and untrusted reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the security review on PR #5890. - loop-task-file: the project `.qwen/loop.md` is now lstat'd BEFORE the blocking open. A symlinked project file is refused outright — a repo-controlled `.qwen/loop.md -> ../.env` resolves inside the workspace, so the realpath confinement alone would pass and exfiltrate that file to the model. A FIFO/socket/device/dir is refused too, so a named pipe can no longer wedge the tick on a blocking `open` that waits for a writer. The ancestor-symlink confinement (`.qwen -> /outside`) is preserved. The home `~/.qwen/loop.md` now follows symlinks (a legitimate dotfiles setup) but stat's the resolved target and requires a regular file, and gained the debug-skip trace the project path already had. - Session/resolver: gate the project loop.md on folder trust. An untrusted folder no longer reads the repo-controlled project file; the user-owned home file stays allowed. Threaded `allowProjectFile` (from `config.isTrustedFolder()`, mirroring getProjectHooks()) through LoopTickResolver into readLoopTaskFile. - nonInteractiveCli: headless cron leak. Skipping a loop sentinel now also deletes a recurring SESSION (non-durable) loop.md job so it stops re-firing and `sessionSize` can fall to zero — otherwise the hold-open never resolves and the run hangs. Durable jobs are left untouched (they persist and don't count toward sessionSize); one-shots are already removed before fire. - Session debug log: emit the non-absolute source label instead of the absolute `sourcePath`, so the resolved path isn't leaked to logs. Tests: project in-workspace-symlink exfiltration guard, FIFO-before-open (no hang, proven by open never being called on the project path), home symlink-to-regular-file, untrusted-folder gating (resolver + Session), absent-tick Session integration, and the headless recurring-session cleanup. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 161 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 8 +- packages/cli/src/nonInteractiveCli.test.ts | 47 ++++- packages/cli/src/nonInteractiveCli.ts | 46 ++++- .../bundled/loop/loop-task-file.test.ts | 110 ++++++++++++ .../src/skills/bundled/loop/loop-task-file.ts | 81 +++++++-- .../bundled/loop/loop-tick-resolver.test.ts | 53 +++++- .../skills/bundled/loop/loop-tick-resolver.ts | 21 ++- 8 files changed, 494 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index b011c577495..42a0051c13e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -158,6 +158,23 @@ function createEmptyStream() { return (async function* () {})(); } +/** + * Points os.homedir() at `home` for a test by overriding the env vars libuv + * reads (HOME on POSIX, USERPROFILE on Windows) — the module export itself can't + * be spied under ESM. Returns a restore function. + */ +function setFakeHome(home: string): () => void { + const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = home; + process.env.USERPROFILE = home; + return () => { + for (const key of ['HOME', 'USERPROFILE'] as const) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + }; +} + // Helper to create async generator with chunks (avoids memory leak) function createStreamWithChunks( chunks: Array<{ type: unknown; value: unknown }>, @@ -335,6 +352,9 @@ describe('Session', () => { getModel: vi.fn().mockImplementation(() => currentModel), getSessionId: vi.fn().mockReturnValue('test-session-id'), getWorkingDir: vi.fn().mockReturnValue(process.cwd()), + // Folder trust gates the project `.qwen/loop.md`; default trusted (the + // production default). Untrusted-folder tests override to false. + isTrustedFolder: vi.fn().mockReturnValue(true), getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), @@ -4497,6 +4517,147 @@ describe('Session', () => { } }); + it('does not expand the project loop.md sentinel in an untrusted folder', async () => { + // An untrusted folder's repo-controlled .qwen/loop.md must not be read + // and fed to the model. With no user-owned ~/.qwen/loop.md, the tick is + // a labelled no-op — and the repo task block never reaches the model. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-untrusted-'), + ); + const fakeHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-home-'), + ); + const loopMdPath = path.join(tmpDir, '.qwen', 'loop.md'); + await fs.mkdir(path.dirname(loopMdPath), { recursive: true }); + await fs.writeFile(loopMdPath, '- finish the migration'); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + // Point os.homedir() at an empty fake home (libuv reads HOME/USERPROFILE) + // so there is no user-owned loop.md and the tick is deterministically + // absent — the module export can't be spied under ESM. + const restoreHome = setFakeHome(fakeHome); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ + prompt: '<>', + cronExpr: '@wakeup', + }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(createEmptyStream()); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The client sees the absent label, never the repo file's path. + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'Loop tick — loop.md not present', + }, + _meta: { source: 'loop' }, + }, + }); + }); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + await vi.waitFor(() => { + expect(sentToModel()).toContain('# /loop tick — loop.md absent'); + }); + // The repo-controlled task block never reaches the model. + expect(sentToModel()).not.toContain('finish the migration'); + } finally { + restoreHome(); + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); + + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { + // The `loopTick && !loopTick.sourcePath` branch: a sentinel fires but no + // project or home loop.md exists, so the tick is a labelled no-op. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-absent-'), + ); + const fakeHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-home-'), + ); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + const restoreHome = setFakeHome(fakeHome); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(createEmptyStream()); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'Loop tick — loop.md not present', + }, + _meta: { source: 'cron' }, + }, + }); + }); + } finally { + restoreHome(); + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); + it('leaves a non-sentinel cron prompt untouched (no loop.md expansion)', async () => { const scheduler = { size: 1, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c2e1f13feee..742c84a0c31 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2467,6 +2467,12 @@ export class Session implements SessionContext { this.loopTickResolver = new LoopTickResolver({ projectRoot: root, homeDir: os.homedir(), + // The project `.qwen/loop.md` is repo-controlled, so an untrusted folder + // must not read it and feed it to the model (mirrors getProjectHooks()'s + // trust gate). The home/global `~/.qwen/loop.md` is user-owned and stays + // allowed. Folder trust is process-stable (a change restarts the CLI), + // so capturing it at construction is sufficient. + allowProjectFile: this.config.isTrustedFolder(), }); this.loopTickResolverRoot = root; } @@ -2523,7 +2529,7 @@ export class Session implements SessionContext { : loopTick.sourcePath ? 'reminder' : 'absent' - } path=${loopTick.sourcePath ?? 'none'}`, + } source=${loopTick.sourceLabel ?? 'none'}`, ); } // For a loop tick echo a stable label, never the bare sentinel or diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 0f9dab4330f..f39915a5c30 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -6,6 +6,7 @@ import type { Config, + CronJob, ToolRegistry, ServerGeminiStreamEvent, SessionMetrics, @@ -22,9 +23,14 @@ import { ApprovalMode, SendMessageType, LoopType, + CronScheduler, + LOOP_SENTINEL_CRON, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; -import { runNonInteractive } from './nonInteractiveCli.js'; +import { + runNonInteractive, + skipHeadlessLoopSentinel, +} from './nonInteractiveCli.js'; import { vi, type Mock, type MockInstance } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; @@ -73,6 +79,45 @@ vi.mock('./services/CommandService.js', () => ({ }, })); +describe('skipHeadlessLoopSentinel', () => { + it('deletes a recurring session loop.md sentinel job so sessionSize reaches 0', () => { + // A recurring SESSION (non-durable) loop.md job left in the scheduler keeps + // sessionSize > 0, so the headless hold-open never resolves and the run + // hangs. Skipping the sentinel must delete the job, not just no-op the tick. + const scheduler = new CronScheduler(); + const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, true); + expect(scheduler.sessionSize).toBe(1); + + expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); + + expect(scheduler.sessionSize).toBe(0); + expect(scheduler.list()).toHaveLength(0); + }); + + it('returns false and keeps a non-sentinel job', () => { + const scheduler = new CronScheduler(); + scheduler.create('*/5 * * * *', 'do real work', true); + const job = scheduler.list()[0] as CronJob; + + expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(false); + + expect(scheduler.sessionSize).toBe(1); + }); + + it('does not delete a durable sentinel job (it persists for a future session)', () => { + // Durable jobs live under ~/.qwen and never count toward sessionSize, so + // they don't pin the run; deleting one would wrongly remove it from disk. + const scheduler = new CronScheduler(); + const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, true); + job.durable = true; + const deleteSpy = vi.spyOn(scheduler, 'delete'); + + expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); + + expect(deleteSpy).not.toHaveBeenCalled(); + }); +}); + describe('runNonInteractive', () => { let mockConfig: Config; let mockSettings: LoadedSettings; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index a0107e38d78..09ce1face38 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -7,6 +7,8 @@ import type { BackgroundTaskStatus, Config, + CronJob, + CronScheduler, ToolCallRequestInfo, } from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; @@ -137,6 +139,36 @@ function formatLoopDetectedMessage(loopType: LoopType | undefined): string { return `Loop detection halted the run${detail}.${hint}`; } +/** + * Headless handling for a fired `.qwen/loop.md` cron sentinel. loop.md + * expansion is interactive-only for now, so a bare sentinel can't be turned + * into a real prompt here — the tick is skipped (no-op) rather than sent to the + * model as empty content. Returns true when `job` was a sentinel so the caller + * skips enqueuing it. + * + * A recurring SESSION (non-durable) loop.md job would otherwise stay in + * `scheduler.sessionSize` and re-fire every interval, pinning the headless run + * open forever (the hold-open resolves only when sessionSize hits zero); delete + * it so the run can terminate. Durable jobs are left untouched — they persist + * for a future owning session and never count toward sessionSize — and a + * one-shot job is already removed before it fires. + */ +export function skipHeadlessLoopSentinel( + scheduler: CronScheduler, + job: CronJob, +): boolean { + if (!detectLoopSentinel(job.prompt)) { + return false; + } + if (job.recurring && !job.durable) { + // delete() removes the in-memory job synchronously before any await, so the + // sessionSize check that follows this call sees it gone; the returned + // promise has no on-disk work for a session job. Fire-and-forget. + void scheduler.delete(job.id); + } + return true; +} + function emitLoopDetectedMessage( config: Config, loopType: LoopType | undefined, @@ -1597,13 +1629,13 @@ export async function runNonInteractive( reject(err); }; - scheduler.start((job: { prompt: string; cronExpr?: string }) => { - // loop.md sentinel expansion is interactive-only for now; in a - // headless run a bare `<>` sentinel would reach the - // model as its prompt with no task content, so skip the tick - // (no-op) instead of enqueuing it. Full headless loop.md support - // is a follow-up. - if (detectLoopSentinel(job.prompt)) { + scheduler.start((job: CronJob) => { + // A bare loop.md sentinel can't expand in a headless run, so the + // tick is skipped. skipHeadlessLoopSentinel also deletes a + // recurring session job so it stops re-firing and sessionSize + // can fall to zero — otherwise checkCronDone never resolves and + // the run hangs. Full headless loop.md support is a follow-up. + if (skipHeadlessLoopSentinel(scheduler, job)) { checkCronDone(); return; } diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index e172bf7e05a..0cfdfd9f91f 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -145,6 +145,116 @@ describe('readLoopTaskFile', () => { }); }); + it('does not read a project loop.md symlinked to an in-workspace file (exfiltration guard)', async () => { + // The dangerous case confinement alone misses: a repo-committed + // `.qwen/loop.md -> ../.env` resolves INSIDE the workspace, so the realpath + // confinement passes — yet it must NOT be read. A symlinked project loop.md + // is refused outright; only a real regular file at the literal path is read. + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + const secret = path.join(projectRoot, '.env'); + await fs.writeFile(secret, 'SECRET=should-not-be-read'); + await fs.symlink( + path.join('..', '.env'), + path.join(projectRoot, '.qwen', 'loop.md'), + ); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'user tasks', + truncated: false, + }); + }); + + it('skips a FIFO/non-regular project loop.md before opening it (does not hang)', async () => { + // A FIFO at the project path must be rejected BEFORE the blocking fs.open: + // open() on a FIFO blocks until a writer appears, wedging the tick forever. + // Drive a FIFO-typed node via a mocked lstat (a real mkfifo is platform- + // fragile); the load-bearing proof is that fs.open is never called on the + // project path, so no blocking open() can happen. + await writeHome('user tasks'); + const projectLoop = path.join(projectRoot, '.qwen', 'loop.md'); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const fifoStat = { + isSymbolicLink: () => false, + isFile: () => false, + isFIFO: () => true, + } as unknown as Awaited>; + vi.spyOn(fs, 'lstat').mockImplementation(async (p) => + String(p) === projectLoop ? fifoStat : actual.lstat(p as string), + ); + const openSpy = vi.mocked(fs.open); + openSpy.mockClear(); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toMatchObject({ source: 'home', content: 'user tasks' }); + // The project FIFO path is never opened — proof there is no blocking open(). + for (const call of openSpy.mock.calls) { + expect(String(call[0])).not.toBe(projectLoop); + } + }); + + it('reads a home loop.md that is a symlink to a real regular file', async () => { + // The user's own dotfile may legitimately be a symlink (e.g. into a synced + // dotfiles repo). Follow it, as long as the target is a real regular file. + await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true }); + const target = path.join(tempDir, 'dotfiles-loop.md'); + await fs.writeFile(target, 'symlinked user tasks'); + await fs.symlink(target, path.join(homeDir, '.qwen', 'loop.md')); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'symlinked user tasks', + truncated: false, + }); + }); + + it('skips the project candidate entirely when allowProjectFile is false', async () => { + // Untrusted folder: the repo-controlled project loop.md is not read even + // when present; the user-owned home loop.md still is. + await writeProject('repo-controlled tasks'); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: false, + }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'user tasks', + truncated: false, + }); + }); + + it('reports only the home path as missing when allowProjectFile is false', async () => { + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: false, + }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [path.join(homeDir, '.qwen', 'loop.md')], + }); + }); + it('skips a non-directory component at .qwen (ENOTDIR) and falls through', async () => { // A regular file where the `.qwen` dir should be → reading .qwen/loop.md // raises ENOTDIR; skip to home rather than throwing. diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 406b433c7a2..b459a6f28a4 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -32,6 +32,13 @@ export type LoopTaskFileResult = export interface ReadLoopTaskFileOptions { projectRoot: string; homeDir: string; + /** + * When false, the project `.qwen/loop.md` candidate is skipped entirely — it + * is repo-controlled, so an untrusted workspace must not read it and feed it + * to the model (mirrors the folder-trust gate on project hooks). The + * home/global `~/.qwen/loop.md` is user-owned and always allowed. Default true. + */ + allowProjectFile?: boolean; } /** @@ -92,27 +99,48 @@ async function readBoundedTaskFile(filePath: string): Promise { /** * Reads `.qwen/loop.md`, project before home, byte-capped at 25 KB. A missing, - * directory, non-directory-component, or empty (whitespace-only) path is skipped - * to the next candidate rather than treated as present; all candidates exhausted - * → missing. Only the byte cap lives here — the fire-time resolver owns the - * user-facing truncation notice so the byte-vs-line nuance stays in one place. + * directory, non-regular, or empty (whitespace-only) path is skipped to the next + * candidate rather than treated as present; all candidates exhausted → missing. + * Only the byte cap lives here — the fire-time resolver owns the user-facing + * truncation notice so the byte-vs-line nuance stays in one place. * - * The project candidate is workspace-confined: its canonical real path must stay - * inside the project root. `fs.realpath` resolves `..` and every symlink — - * including an *ancestor* like a checked-in `.qwen -> /outside`, which a - * final-component `lstat` cannot catch — so a project loop.md cannot read a file - * outside the workspace. The home candidate is the user's own and intentionally - * outside the workspace, so it only refuses a directly-symlinked file. + * Project candidate: must be a real regular file at the literal path, and is + * stat'd BEFORE the blocking open. A symlinked `.qwen/loop.md` is refused + * outright — a repo-controlled symlink such as `-> ../.env` resolves *inside* + * the workspace, so confinement alone would pass and exfiltrate that file to the + * model. A FIFO/socket/device/dir is refused too, so a named pipe can never + * wedge the tick (a blocking `open` on a FIFO waits for a writer) or be read as + * a task list. The canonical path is still confined to the workspace root to + * catch an *ancestor* symlink like a checked-in `.qwen -> /outside` that a + * final-component `lstat` cannot see. When `allowProjectFile` is false (untrusted + * folder) the candidate is dropped entirely. + * + * Home candidate: the user's own dotfile, so a symlink IS followed (a common, + * legitimate setup — e.g. into a synced dotfiles repo), but the resolved target + * must be a regular file so a FIFO/device/dir can't hang the tick or be decoded. */ export async function readLoopTaskFile({ projectRoot, homeDir, + allowProjectFile = true, }: ReadLoopTaskFileOptions): Promise { + if (!allowProjectFile) { + // Repo-controlled file in an untrusted folder — never read it (the + // candidate is dropped below; this is the trace for why). + debugLogger.debug('skipping project loop.md: folder is untrusted'); + } const candidates: ReadonlyArray<{ source: LoopTaskFileSource; path: string; }> = [ - { source: 'project', path: path.join(projectRoot, '.qwen', 'loop.md') }, + ...(allowProjectFile + ? [ + { + source: 'project' as const, + path: path.join(projectRoot, '.qwen', 'loop.md'), + }, + ] + : []), { source: 'home', path: path.join(homeDir, '.qwen', 'loop.md') }, ]; @@ -120,11 +148,27 @@ export async function readLoopTaskFile({ let buffer: Buffer | null; try { if (source === 'project') { + // lstat WITHOUT following the final component, BEFORE the blocking open. + // A symlinked loop.md is the exfiltration vector (it may point at an + // in-workspace `.env`, which confinement would wave through), so refuse + // it; a FIFO/socket/device/dir is refused too so open can never block. + const projectStat = await fs.lstat(filePath); + if (projectStat.isSymbolicLink()) { + debugLogger.debug('skipping symlinked project loop.md', { filePath }); + continue; + } + if (!projectStat.isFile()) { + debugLogger.debug('skipping non-regular project loop.md', { + filePath, + }); + continue; + } + // A final-component lstat can't see an ANCESTOR symlink (e.g. a + // checked-in `.qwen -> /outside`); realpath resolves it, so confine the + // canonical path to the workspace root before reading. const realRoot = await resolveRealProjectRoot(projectRoot); const real = await fs.realpath(filePath); if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { - // Skip silently to the next candidate, but leave a debug trail so a - // symlink quietly redirecting loop.md outside the workspace is traceable. debugLogger.debug( 'skipping project loop.md that escapes the workspace', { @@ -136,10 +180,13 @@ export async function readLoopTaskFile({ } buffer = await readBoundedTaskFile(real); } else { - // lstat (not stat) so a directly symlinked home loop.md is detected - // rather than followed. - const stat = await fs.lstat(filePath); - if (stat.isSymbolicLink()) { + // Home loop.md is the user's own dotfile: a symlink is a legitimate, + // common setup, so follow it (stat, not lstat). But require the resolved + // target to be a regular file so a FIFO/device/dir can neither hang the + // tick on a blocking open nor be decoded as a task list. + const homeStat = await fs.stat(filePath); + if (!homeStat.isFile()) { + debugLogger.debug('skipping non-regular home loop.md', { filePath }); continue; } buffer = await readBoundedTaskFile(filePath); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index d579a61426e..6ecc70158db 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -53,13 +53,52 @@ describe('LoopTickResolver', () => { homeDir = path.join(tempDir, 'home'); await fs.mkdir(projectRoot, { recursive: true }); await fs.mkdir(homeDir, { recursive: true }); - resolver = new LoopTickResolver({ projectRoot, homeDir }); + resolver = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: true, + }); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); + it('ignores the project loop.md in an untrusted folder (allowProjectFile: false)', async () => { + // An untrusted folder's repo-controlled project loop.md must not be read, + // but the user-owned home loop.md still is. + await writeProject('- repo-controlled tasks'); + await writeHome('- user tasks'); + const untrusted = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: false, + }); + + const tick = await untrusted.resolve('cron'); + + expect(tick.full).toBe(true); + expect(tick.sourcePath).toBe(homeFile()); + expect(tick.sourceLabel).toBe('home loop.md'); + expect(tick.modelText).toContain('- user tasks'); + expect(tick.modelText).not.toContain('- repo-controlled tasks'); + }); + + it('treats a present project loop.md as absent when the folder is untrusted', async () => { + await writeProject('- repo-controlled tasks'); + const untrusted = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: false, + }); + + const tick = await untrusted.resolve('cron'); + + expect(tick.full).toBe(false); + expect(tick.sourcePath).toBeUndefined(); + expect(tick.modelText).toContain('loop.md is not currently present'); + }); + it('delivers the full task block on first fire', async () => { await writeProject('- ship the thing'); @@ -153,7 +192,11 @@ describe('LoopTickResolver', () => { const cron = await resolver.resolve('cron'); expect(cron.modelText).toContain('# /loop tick — loop.md absent\n'); - const dyn = new LoopTickResolver({ projectRoot, homeDir }); + const dyn = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: true, + }); const dynTick = await dyn.resolve('dynamic'); expect(dynTick.modelText).toContain( '# /loop tick — loop.md absent (dynamic pacing)\n', @@ -191,7 +234,11 @@ describe('LoopTickResolver', () => { expect(cron.modelText).not.toContain('(dynamic pacing)'); // Fresh resolver so 'dynamic' is also a first (full) delivery. - const dyn = new LoopTickResolver({ projectRoot, homeDir }); + const dyn = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: true, + }); const dynTick = await dyn.resolve('dynamic'); expect(dynTick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); expect(dynTick.modelText).toContain('call LoopWakeup again'); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 5ccd514f0e9..c0090792b8e 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -34,6 +34,11 @@ export interface LoopTickResolverDeps { /** Pass `config.getWorkingDir()` — loop.md is resolved against the cwd. */ projectRoot: string; homeDir: string; + /** + * Pass `config.isTrustedFolder()`. When false, the repo-controlled project + * `.qwen/loop.md` is not read (the user-owned `~/.qwen/loop.md` still is). + */ + allowProjectFile: boolean; } export interface LoopTickResult { @@ -43,6 +48,9 @@ export interface LoopTickResult { full: boolean; /** Resolved loop.md path, when present — for a clean user-facing label. */ sourcePath?: string; + /** Non-absolute label for the matched candidate (e.g. "project loop.md"), + * when present — safe for logs/UI that must not leak the absolute path. */ + sourceLabel?: string; } const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; @@ -141,6 +149,7 @@ export class LoopTickResolver { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, + allowProjectFile: this.deps.allowProjectFile, }); if (result.status === 'missing') { @@ -161,22 +170,26 @@ export class LoopTickResolver { : result.content; this.#pendingContent = content; + // Label by which candidate matched, not result.path (the absolute path) — + // the absolute path would leak the OS username / dir layout to the API + // provider, and to debug logs. It still reaches the caller via sourcePath + // for local UI use. + const sourceLabel = SOURCE_LABELS[result.source]; + if (this.#lastContent === content) { return { modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_BODY[mode]}`, full: false, sourcePath: result.path, + sourceLabel, }; } - // Label by which candidate matched, not result.path (the absolute path) — - // the absolute path would leak the OS username / dir layout to the API - // provider. It still reaches the caller via sourcePath for local UI use. - const sourceLabel = SOURCE_LABELS[result.source]; return { modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${SHORT_REMINDER_BODY[mode]}`, full: true, sourcePath: result.path, + sourceLabel, }; } } From 1ad5362ed0e0861a9172addb037ed4e77aa362f0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 05:50:47 +0800 Subject: [PATCH 10/31] fix(loop): bracket-access process.env in Session test setFakeHome `noPropertyAccessFromIndexSignature` (root tsconfig) rejects dot access on the `NodeJS.ProcessEnv` index signature, so the dot-notation `process.env.HOME` / `process.env.USERPROFILE` reads and writes in the `setFakeHome` helper failed `tsc --noEmit` with 4x TS4111, breaking CI (packages/cli includes *.test.ts in its typecheck). Switch them to bracket access to match the existing restore loop. Co-Authored-By: Qwen-Coder --- packages/cli/src/acp-integration/session/Session.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 42a0051c13e..d1eca694998 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -164,9 +164,12 @@ function createEmptyStream() { * be spied under ESM. Returns a restore function. */ function setFakeHome(home: string): () => void { - const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; - process.env.HOME = home; - process.env.USERPROFILE = home; + const prev = { + HOME: process.env['HOME'], + USERPROFILE: process.env['USERPROFILE'], + }; + process.env['HOME'] = home; + process.env['USERPROFILE'] = home; return () => { for (const key of ['HOME', 'USERPROFILE'] as const) { if (prev[key] === undefined) delete process.env[key]; From 3e19ba57bb84dd4a7f8254945d3ef6c0020a3a6f Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 07:28:01 +0800 Subject: [PATCH 11/31] fix(loop): fail-secure loop.md default + root confinement, with coverage Address qwen-code-ci-bot review on the loop.md injection change: - readLoopTaskFile's `allowProjectFile` now defaults to false. It is re-exported from the core barrel, so a caller that omits the option must not silently read an untrusted workspace's repo-controlled `.qwen/loop.md`. The real caller (Session via LoopTickResolver) already passes the trust-derived value explicitly, so trusted-folder behavior is unchanged. - Fix a workspace-confinement false positive at a filesystem root: when realRoot is `/` (or `C:\`), `realRoot + path.sep` became `//` (`C:\\`), which no descendant startsWith, so running from the root wrongly refused every project loop.md. Derive the prefix without double-appending the separator and allow `real === realRoot`. - Add coverage: the dynamic loop.md sentinel is cleaned up headlessly like the cron one (guards against a `=== LOOP_SENTINEL_CRON` regression); an aborted (undelivered) tick followed by an edit re-delivers the full new block (the #pendingContent vs #lastContent divergence); a project loop.md directly under a filesystem root is not falsely refused; and the new fail-secure default is locked in. Co-Authored-By: Qwen-Coder --- packages/cli/src/nonInteractiveCli.test.ts | 16 ++ .../bundled/loop/loop-task-file.test.ts | 167 +++++++++++++++--- .../src/skills/bundled/loop/loop-task-file.ts | 18 +- .../bundled/loop/loop-tick-resolver.test.ts | 19 ++ 4 files changed, 193 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index f39915a5c30..5bed522be60 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -25,6 +25,7 @@ import { LoopType, CronScheduler, LOOP_SENTINEL_CRON, + LOOP_SENTINEL_DYNAMIC, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { @@ -94,6 +95,21 @@ describe('skipHeadlessLoopSentinel', () => { expect(scheduler.list()).toHaveLength(0); }); + it('also cleans up a recurring session job for the dynamic sentinel', () => { + // Mirror of the cron case for `<>`. skipHeadlessLoopSentinel + // must route through detectLoopSentinel (which matches BOTH sentinels), not a + // `=== LOOP_SENTINEL_CRON` comparison — otherwise a dynamic loop.md job would + // pin sessionSize > 0 and hang the headless run. + const scheduler = new CronScheduler(); + const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_DYNAMIC, true); + expect(scheduler.sessionSize).toBe(1); + + expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); + + expect(scheduler.sessionSize).toBe(0); + expect(scheduler.list()).toHaveLength(0); + }); + it('returns false and keeps a non-sentinel job', () => { const scheduler = new CronScheduler(); scheduler.create('*/5 * * * *', 'do real work', true); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 0cfdfd9f91f..8ed4c5d63ce 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -82,7 +82,11 @@ describe('readLoopTaskFile', () => { await writeProject('project tasks'); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -96,7 +100,11 @@ describe('readLoopTaskFile', () => { it('falls back to the user loop task file', async () => { await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -114,7 +122,11 @@ describe('readLoopTaskFile', () => { await fs.symlink(outside, path.join(projectRoot, '.qwen', 'loop.md')); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -134,7 +146,11 @@ describe('readLoopTaskFile', () => { await fs.symlink(outside, path.join(projectRoot, '.qwen')); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -159,7 +175,11 @@ describe('readLoopTaskFile', () => { ); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -170,6 +190,37 @@ describe('readLoopTaskFile', () => { }); }); + it('does not falsely refuse a project loop.md when the workspace root is a filesystem root', async () => { + // When the CLI runs from a filesystem root, realRoot is `/` (or `C:\`), so the + // old `realRoot + path.sep` prefix became `//` (`C:\\`) — which no descendant + // startsWith, wrongly refusing every project loop.md. Drive realRoot to the + // filesystem root via a realpath mock; the real loop.md still resolves to a + // normal absolute path (a descendant of the root) and must be read, not refused. + await writeProject('- root-level tasks'); + const root = path.parse(projectRoot).root; // '/' on POSIX, e.g. 'C:\\' on Windows + const actual = + await vi.importActual( + 'node:fs/promises', + ); + vi.spyOn(fs, 'realpath').mockImplementation((p) => + String(p) === projectRoot + ? Promise.resolve(root) + : actual.realpath(p as string), + ); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toMatchObject({ + status: 'found', + source: 'project', + content: '- root-level tasks', + }); + }); + it('skips a FIFO/non-regular project loop.md before opening it (does not hang)', async () => { // A FIFO at the project path must be rejected BEFORE the blocking fs.open: // open() on a FIFO blocks until a writer appears, wedging the tick forever. @@ -193,7 +244,11 @@ describe('readLoopTaskFile', () => { const openSpy = vi.mocked(fs.open); openSpy.mockClear(); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toMatchObject({ source: 'home', content: 'user tasks' }); // The project FIFO path is never opened — proof there is no blocking open(). @@ -210,7 +265,11 @@ describe('readLoopTaskFile', () => { await fs.writeFile(target, 'symlinked user tasks'); await fs.symlink(target, path.join(homeDir, '.qwen', 'loop.md')); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -221,6 +280,24 @@ describe('readLoopTaskFile', () => { }); }); + it('defaults to fail-secure: omitting allowProjectFile skips the project file', async () => { + // This function is re-exported from the core barrel; an external caller that + // forgets the option must NOT read the repo-controlled project loop.md from + // an untrusted workspace. The default is false — callers opt IN to trust. + await writeProject('repo-controlled tasks'); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ projectRoot, homeDir }); + + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'user tasks', + truncated: false, + }); + }); + it('skips the project candidate entirely when allowProjectFile is false', async () => { // Untrusted folder: the repo-controlled project loop.md is not read even // when present; the user-owned home loop.md still is. @@ -261,7 +338,11 @@ describe('readLoopTaskFile', () => { await fs.writeFile(path.join(projectRoot, '.qwen'), 'not a dir'); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -279,7 +360,11 @@ describe('readLoopTaskFile', () => { }); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -299,9 +384,9 @@ describe('readLoopTaskFile', () => { }); vi.mocked(fs.open).mockRejectedValueOnce(eacces); - await expect(readLoopTaskFile({ projectRoot, homeDir })).rejects.toThrow( - /EACCES/, - ); + await expect( + readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }), + ).rejects.toThrow(/EACCES/); }); it('evicts the cached project-root realpath after a transient failure and retries on the next tick', async () => { @@ -325,13 +410,17 @@ describe('readLoopTaskFile', () => { realpathSpy.mockImplementation((p) => actual.realpath(p as string)); // First tick: the transient error surfaces (current per-tick semantics). - await expect(readLoopTaskFile({ projectRoot, homeDir })).rejects.toThrow( - /EACCES/, - ); + await expect( + readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }), + ).rejects.toThrow(/EACCES/); // Second tick: the poisoned entry was evicted, so realpath is retried and // the project loop.md resolves — proving the rejection was not cached. - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -349,7 +438,11 @@ describe('readLoopTaskFile', () => { await writeProject(' \n\t \n'); await writeHome('user tasks'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'found', @@ -364,7 +457,11 @@ describe('readLoopTaskFile', () => { await writeProject(''); await writeHome('\n \n'); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toEqual({ status: 'missing', @@ -376,7 +473,9 @@ describe('readLoopTaskFile', () => { }); it('returns a missing result when no task file exists', async () => { - await expect(readLoopTaskFile({ projectRoot, homeDir })).resolves.toEqual({ + await expect( + readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }), + ).resolves.toEqual({ status: 'missing', checkedPaths: [ path.join(projectRoot, '.qwen', 'loop.md'), @@ -388,7 +487,11 @@ describe('readLoopTaskFile', () => { it('byte-caps task files above the cap and flags them truncated', async () => { await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES + 5)); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result.status).toBe('found'); if (result.status === 'found') { @@ -410,7 +513,11 @@ describe('readLoopTaskFile', () => { openSpy.mockClear(); const readLengths = await recordHandleReadLengths(); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result.status).toBe('found'); if (result.status === 'found') { @@ -437,7 +544,11 @@ describe('readLoopTaskFile', () => { const cap = LOOP_TASK_FILE_MAX_BYTES + 1; const readLengths = await recordHandleReadLengths(); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result).toMatchObject({ status: 'found', @@ -453,7 +564,11 @@ describe('readLoopTaskFile', () => { it('does not truncate task files at exactly the byte cap', async () => { await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES)); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result.status).toBe('found'); if (result.status === 'found') { @@ -468,7 +583,11 @@ describe('readLoopTaskFile', () => { // 3-byte chars make the raw byte cap land mid-character. await writeProject('一'.repeat(LOOP_TASK_FILE_MAX_BYTES)); - const result = await readLoopTaskFile({ projectRoot, homeDir }); + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); expect(result.status).toBe('found'); if (result.status === 'found') { diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index b459a6f28a4..7b13cb415a9 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -36,7 +36,12 @@ export interface ReadLoopTaskFileOptions { * When false, the project `.qwen/loop.md` candidate is skipped entirely — it * is repo-controlled, so an untrusted workspace must not read it and feed it * to the model (mirrors the folder-trust gate on project hooks). The - * home/global `~/.qwen/loop.md` is user-owned and always allowed. Default true. + * home/global `~/.qwen/loop.md` is user-owned and always allowed. + * + * Defaults to false (fail-secure): this function is re-exported from the core + * barrel, so a caller that omits the option must NOT silently read an + * untrusted workspace's repo-controlled file — callers opt IN by passing the + * trust-derived value explicitly. */ allowProjectFile?: boolean; } @@ -122,7 +127,7 @@ async function readBoundedTaskFile(filePath: string): Promise { export async function readLoopTaskFile({ projectRoot, homeDir, - allowProjectFile = true, + allowProjectFile = false, }: ReadLoopTaskFileOptions): Promise { if (!allowProjectFile) { // Repo-controlled file in an untrusted folder — never read it (the @@ -168,7 +173,14 @@ export async function readLoopTaskFile({ // canonical path to the workspace root before reading. const realRoot = await resolveRealProjectRoot(projectRoot); const real = await fs.realpath(filePath); - if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { + // Don't double-append the separator: at a filesystem root realRoot is + // already `/` (or `C:\`), so `realRoot + path.sep` would be `//` / `C:\\` + // — which no descendant startsWith, wrongly refusing every project + // loop.md when the CLI runs from the root. Allow real === realRoot too. + const prefix = realRoot.endsWith(path.sep) + ? realRoot + : realRoot + path.sep; + if (real !== realRoot && !real.startsWith(prefix)) { debugLogger.debug( 'skipping project loop.md that escapes the workspace', { diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 6ecc70158db..783a511898f 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -150,6 +150,25 @@ describe('LoopTickResolver', () => { expect((await resolver.resolve('dynamic')).full).toBe(false); }); + it('re-delivers the full NEW block when an undelivered tick is followed by an edit', async () => { + // First tick resolved but ABORTED before delivery (no markDelivered), then the + // file is edited. Delivered content (#lastContent) is still null, so the second + // resolve must emit the FULL block with the NEW content — this is the + // #pendingContent-vs-#lastContent divergence path. If #pendingContent were + // committed eagerly on resolve(), the first tick would collapse to a short + // reminder (full=false), pointing the model at a block it never received. + await writeProject('- v1 tasks'); + expect((await resolver.resolve('dynamic')).full).toBe(true); + // No markDelivered() — the first tick never reached the model. + + await writeProject('- v2 edited tasks'); + const tick = await resolver.resolve('dynamic'); + + expect(tick.full).toBe(true); + expect(tick.modelText).toContain('The user configured a loop-tasks file.'); + expect(tick.modelText).toContain('- v2 edited tasks'); + }); + it('re-delivers the full block when loop.md is edited', async () => { await writeProject('- v1'); await resolver.resolve('dynamic'); From 60edea8eaaffe7c6e5d8d5f3fc30ad62069def82 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 09:21:17 +0800 Subject: [PATCH 12/31] fix(loop): harden loop.md reader/resolver per CI review Follow-up hardening on the loop.md task-file pipeline: - loop-task-file: add ELOOP/ENAMETOOLONG to the skip whitelist so a symlink-loop or over-long loop.md path is treated as a skippable candidate instead of crashing the tick (EACCES still surfaces). - loop-task-file: confine the home ~/.qwen/loop.md symlink target to $HOME via realpath, blocking out-of-home escapes (e.g. -> /etc/passwd) while still allowing in-home dotfile symlinks. Shared prefix-confinement helper now used by both the project and home candidates. - Session: echo the relative sourceLabel (not the absolute sourcePath) to the ACP client so the OS username / dir layout isn't leaked into the UI. - loop-tick-resolver: drop the contradictory "established earlier" reminder on the first full delivery (the block is in the same message); keep the mode-specific pacing suffix on both full and short ticks. - loop-tick-resolver: document the intentional cut > 0 boundary in cutToLastNewline (a position-0 newline keeps the body rather than emptying it). Tests: ELOOP skip, home-symlink escape vs in-home target, home non-regular-file skip, position-0 newline, first-delivery wording, and a Session-level assertion that an auto-compaction resets the resolver cache so the next unchanged tick re-expands. All new tests mutation-checked. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 102 +++++++++++++++++- .../src/acp-integration/session/Session.ts | 10 +- .../bundled/loop/loop-task-file.test.ts | 87 ++++++++++++++- .../src/skills/bundled/loop/loop-task-file.ts | 83 +++++++++----- .../bundled/loop/loop-tick-resolver.test.ts | 31 +++++- .../skills/bundled/loop/loop-tick-resolver.ts | 30 ++++-- 6 files changed, 300 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index d1eca694998..146175242ee 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4481,7 +4481,8 @@ describe('Session', () => { prompt: [{ type: 'text', text: 'hello' }], }); - // The client sees a stable label, never the raw sentinel. + // The client sees a stable RELATIVE label, never the raw sentinel or + // the absolute path (which would leak the OS username / dir layout). await vi.waitFor(() => { expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ sessionId: 'test-session-id', @@ -4489,12 +4490,21 @@ describe('Session', () => { sessionUpdate: 'user_message_chunk', content: { type: 'text', - text: `Loop tick — tasks from ${loopMdPath}`, + text: 'Loop tick — tasks from project loop.md', }, _meta: { source: 'loop' }, }, }); }); + // The absolute loop.md path must not appear in any client echo. + for (const call of ( + mockClient.sessionUpdate as ReturnType + ).mock.calls) { + const text = call[0]?.update?.content?.text; + if (typeof text === 'string') { + expect(text).not.toContain(loopMdPath); + } + } // The model receives the expanded full task block, not the sentinel. let block = ''; @@ -4712,6 +4722,94 @@ describe('Session', () => { expect(sentToModel()).not.toContain('# /loop tick'); }); + it('re-expands the full loop.md block after an auto-compaction resets the resolver cache', async () => { + // LoopTickResolver.resetCache() is unit-tested in isolation; this pins + // the Session-level wiring: an auto-compaction in the send path + // (#sendMessageStreamWithAutoCompression) must reset the resolver so the + // next unchanged tick re-delivers the FULL block (a short reminder would + // point back to a task block compaction just evicted from context). + // + // Three unchanged ticks: tick1 full (committed), tick2 would normally be + // a short reminder but COMPACTS mid-send, tick3 re-expands FULL purely + // because tick2's compaction reset the cache. The INTRO line therefore + // appears in exactly the two full deliveries (tick1 + tick3); without + // the reset it would appear only once. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-compact-'), + ); + const loopMdPath = path.join(tmpDir, '.qwen', 'loop.md'); + await fs.mkdir(path.dirname(loopMdPath), { recursive: true }); + await fs.writeFile(loopMdPath, '- stable task list'); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + + // Compress on the SECOND cron tick only — keyed on the cron promptId so + // the user 'hello' prompt's compression check stays a no-op. + let cronCompressions = 0; + mockGeminiClient.tryCompressChat = vi + .fn() + .mockImplementation(async (promptId: string) => { + const isCron = String(promptId).includes('cron'); + if (isCron) cronCompressions++; + const compressed = isCron && cronCompressions === 2; + return { + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: compressed + ? core.CompressionStatus.COMPRESSED + : core.CompressionStatus.NOOP, + }; + }); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + // Three ticks of the same sentinel; the cron queue drains them + // serially against the one persistent resolver. + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const fullDeliveries = () => + ( + mockChat.sendMessageStream as ReturnType + ).mock.calls.filter((c) => + (Array.isArray(c[1]?.message) ? c[1].message : []) + .map((p: { text?: string }) => p.text ?? '') + .join('') + .includes('The user configured a loop-tasks file.'), + ).length; + + // tick1 + tick3 re-expand; tick2 is the (compacting) short reminder. + await vi.waitFor(() => { + expect(fullDeliveries()).toBe(2); + }); + // The compaction actually fired on a cron tick (sanity-check the setup). + expect(cronCompressions).toBeGreaterThanOrEqual(2); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it('stops cron-fired ACP prompt before sending when the session token limit is exceeded', async () => { let cronCallback: ((job: { prompt: string }) => void) | undefined; const scheduler = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 742c84a0c31..9dc3c3682ce 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2532,11 +2532,13 @@ export class Session implements SessionContext { } source=${loopTick.sourceLabel ?? 'none'}`, ); } - // For a loop tick echo a stable label, never the bare sentinel or - // the full task dump; otherwise echo the prompt verbatim. + // For a loop tick echo a stable, relative label — never the bare + // sentinel, the full task dump, or the absolute sourcePath (which + // would leak the OS username / dir layout into the ACP client UI); + // otherwise echo the prompt verbatim. const echoText = loopTick - ? loopTick.sourcePath - ? `Loop tick — tasks from ${loopTick.sourcePath}` + ? loopTick.sourceLabel + ? `Loop tick — tasks from ${loopTick.sourceLabel}` : 'Loop tick — loop.md not present' : prompt; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 8ed4c5d63ce..14e300d7d96 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -257,11 +257,13 @@ describe('readLoopTaskFile', () => { } }); - it('reads a home loop.md that is a symlink to a real regular file', async () => { + it('reads a home loop.md that is a symlink to a real regular file inside $HOME', async () => { // The user's own dotfile may legitimately be a symlink (e.g. into a synced - // dotfiles repo). Follow it, as long as the target is a real regular file. + // dotfiles repo). Follow it, as long as the target is a real regular file + // that resolves WITHIN $HOME (the confinement added for escapes). await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true }); - const target = path.join(tempDir, 'dotfiles-loop.md'); + const target = path.join(homeDir, 'dotfiles', 'loop.md'); + await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, 'symlinked user tasks'); await fs.symlink(target, path.join(homeDir, '.qwen', 'loop.md')); @@ -280,6 +282,85 @@ describe('readLoopTaskFile', () => { }); }); + it('skips a home loop.md whose symlink target escapes $HOME', async () => { + // Home symlinks are allowed (dotfiles repos), but only if they resolve + // WITHIN $HOME. A `~/.qwen/loop.md -> /etc/passwd`-style escape (here a + // sibling outside homeDir) must be skipped, not read and fed to the model. + await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true }); + const outside = path.join(tempDir, 'outside-secret'); + await fs.writeFile(outside, 'SECRET=should-not-be-read'); + await fs.symlink(outside, path.join(homeDir, '.qwen', 'loop.md')); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [ + path.join(projectRoot, '.qwen', 'loop.md'), + path.join(homeDir, '.qwen', 'loop.md'), + ], + }); + }); + + it('skips a home loop.md that is a self-referential symlink (ELOOP) instead of throwing', async () => { + // fs.stat follows the home symlink; a self-referential link raises ELOOP. + // That must be treated as a skippable candidate (→ missing), not crash the + // tick — without ELOOP in the skip whitelist this rethrows and aborts. + await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true }); + const loop = path.join(homeDir, '.qwen', 'loop.md'); + await fs.symlink(loop, loop); // points at itself → ELOOP on stat + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [path.join(projectRoot, '.qwen', 'loop.md'), loop], + }); + }); + + it('skips a home loop.md that resolves to a non-regular file (directory/FIFO)', async () => { + // The home candidate follows symlinks via fs.stat; if the (possibly + // symlinked) target is a directory/FIFO it must be skipped — the project + // path proves this via lstat, but the home path's fs.stat needs its own + // coverage so a blocking open / directory read never happens. + const homeLoop = path.join(homeDir, '.qwen', 'loop.md'); + await fs.mkdir(path.dirname(homeLoop), { recursive: true }); + await fs.writeFile(homeLoop, '- user tasks'); // real file so realpath resolves + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const dirStat = { + isFile: () => false, + isDirectory: () => true, + } as unknown as Awaited>; + vi.spyOn(fs, 'stat').mockImplementation(async (p) => + String(p) === homeLoop ? dirStat : actual.stat(p as string), + ); + const openSpy = vi.mocked(fs.open); + openSpy.mockClear(); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result.status).toBe('missing'); + // The non-regular guard fired before any open() on the home path. + for (const call of openSpy.mock.calls) { + expect(String(call[0])).not.toBe(homeLoop); + } + }); + it('defaults to fail-secure: omitting allowProjectFile skips the project file', async () => { // This function is re-exported from the core barrel; an external caller that // forgets the option must NOT read the repo-controlled project loop.md from diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 7b13cb415a9..6f3b0c3f6c8 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -47,26 +47,41 @@ export interface ReadLoopTaskFileOptions { } /** - * Canonical `fs.realpath(projectRoot)` cache — the workspace-confinement - * boundary for the project loop.md. It is stable for the process, so resolve it - * once per root instead of every tick. Keyed by the TRUSTED projectRoot and - * never accepted from a caller, so an external caller of this re-exported - * function can't widen the boundary with a stale/broader path. + * `fs.realpath(dir)` cache for the two confinement boundaries — the workspace + * root and the home dir. Each is stable for the process, so resolve it once per + * dir instead of every tick. Keyed by the TRUSTED dir the caller passes (never a + * path derived from file contents), so an external caller of this re-exported + * function can't widen a boundary with a stale/broader path. */ -const realProjectRootCache = new Map>(); +const realDirCache = new Map>(); -function resolveRealProjectRoot(projectRoot: string): Promise { - let real = realProjectRootCache.get(projectRoot); +function resolveRealDir(dir: string): Promise { + let real = realDirCache.get(dir); if (real === undefined) { - real = fs.realpath(projectRoot); + real = fs.realpath(dir); // Don't pin a rejection: a transient failure (EACCES, ENOENT) must be // retried next tick rather than cached, preserving per-tick error semantics. - real.catch(() => realProjectRootCache.delete(projectRoot)); - realProjectRootCache.set(projectRoot, real); + real.catch(() => realDirCache.delete(dir)); + realDirCache.set(dir, real); } return real; } +/** + * True when `real` is `root` itself or a descendant of it — the prefix + * confinement shared by the project and home candidates. The separator isn't + * double-appended: at a filesystem root `root` is already `/` (or `C:\`), so + * `root + path.sep` would be `//` / `C:\\`, which no descendant startsWith, + * wrongly refusing everything — so `real === root` is allowed too. + */ +function isWithin(root: string, real: string): boolean { + if (real === root) { + return true; + } + const prefix = root.endsWith(path.sep) ? root : root + path.sep; + return real.startsWith(prefix); +} + /** * Read at most `LOOP_TASK_FILE_MAX_BYTES + 1` bytes — the one extra byte is the * truncation signal and the only thing we need past the cap, so a huge/malicious @@ -122,7 +137,8 @@ async function readBoundedTaskFile(filePath: string): Promise { * * Home candidate: the user's own dotfile, so a symlink IS followed (a common, * legitimate setup — e.g. into a synced dotfiles repo), but the resolved target - * must be a regular file so a FIFO/device/dir can't hang the tick or be decoded. + * must be a regular file AND stay within $HOME so a FIFO/device/dir can't hang + * the tick and an escaping symlink (e.g. `-> /etc/passwd`) can't be exfiltrated. */ export async function readLoopTaskFile({ projectRoot, @@ -171,16 +187,9 @@ export async function readLoopTaskFile({ // A final-component lstat can't see an ANCESTOR symlink (e.g. a // checked-in `.qwen -> /outside`); realpath resolves it, so confine the // canonical path to the workspace root before reading. - const realRoot = await resolveRealProjectRoot(projectRoot); + const realRoot = await resolveRealDir(projectRoot); const real = await fs.realpath(filePath); - // Don't double-append the separator: at a filesystem root realRoot is - // already `/` (or `C:\`), so `realRoot + path.sep` would be `//` / `C:\\` - // — which no descendant startsWith, wrongly refusing every project - // loop.md when the CLI runs from the root. Allow real === realRoot too. - const prefix = realRoot.endsWith(path.sep) - ? realRoot - : realRoot + path.sep; - if (real !== realRoot && !real.startsWith(prefix)) { + if (!isWithin(realRoot, real)) { debugLogger.debug( 'skipping project loop.md that escapes the workspace', { @@ -201,14 +210,36 @@ export async function readLoopTaskFile({ debugLogger.debug('skipping non-regular home loop.md', { filePath }); continue; } - buffer = await readBoundedTaskFile(filePath); + // A home symlink IS followed, but its target must stay WITHIN $HOME: + // otherwise `~/.qwen/loop.md -> /etc/passwd` (or `-> /dev/...`) would be + // read and fed to the model every tick. In-home dotfile symlinks (e.g. + // `-> ~/dotfiles/loop.md`) still resolve inside $HOME and are allowed. + const realHome = await resolveRealDir(homeDir); + const real = await fs.realpath(filePath); + if (!isWithin(realHome, real)) { + debugLogger.debug( + 'skipping home loop.md that escapes the home directory', + { filePath, resolved: real }, + ); + continue; + } + buffer = await readBoundedTaskFile(real); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; - // Absent (ENOENT), a directory (EISDIR), or a non-directory path component - // (ENOTDIR, e.g. a stray file where `.qwen` should be) → try the next - // candidate. Anything else (permissions, I/O) is a real error and surfaces. - if (code === 'ENOENT' || code === 'EISDIR' || code === 'ENOTDIR') { + // None of these name a readable loop.md, so try the next candidate: + // absent (ENOENT), a directory (EISDIR), a non-directory path component + // (ENOTDIR, e.g. a stray file where `.qwen` should be), a symlink loop + // (ELOOP, e.g. a self-referential `~/.qwen/loop.md`), or an over-long path + // (ENAMETOOLONG). Anything else (EACCES permissions, real I/O) surfaces + // rather than being silently swallowed. + if ( + code === 'ENOENT' || + code === 'EISDIR' || + code === 'ENOTDIR' || + code === 'ELOOP' || + code === 'ENAMETOOLONG' + ) { continue; } throw error; diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 783a511898f..af24e49c822 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -113,8 +113,12 @@ describe('LoopTickResolver', () => { expect(tick.modelText).not.toContain(projectFile()); expect(tick.modelText).toContain('The user configured a loop-tasks file.'); expect(tick.modelText).toContain('- ship the thing'); - // The full block ends with the same short reminder an unchanged fire emits. + // The full block carries the mode-specific pacing suffix (dynamic re-arm)... expect(tick.modelText).toContain('(dynamic pacing)'); + expect(tick.modelText).toContain('call LoopWakeup again'); + // ...but NOT the "established earlier" reminder: the block is right here in + // this message, so that phrasing would contradict the INTRO above it. + expect(tick.modelText).not.toContain('established earlier'); // Exactly one H1 in the whole message (no duplicated tick heading). expect(tick.modelText.match(/^# /gm)).toHaveLength(1); }); @@ -133,6 +137,9 @@ describe('LoopTickResolver', () => { expect(tick.modelText).not.toContain( 'The user configured a loop-tasks file.', ); + // A subsequent tick DOES point back to the earlier full block — that + // reminder semantics is intact (only the first delivery omits it). + expect(tick.modelText).toContain('established earlier'); expect(tick.modelText).toContain( '# /loop tick — loop.md tasks (dynamic pacing)', ); @@ -285,6 +292,28 @@ describe('LoopTickResolver', () => { ); }); + it('keeps the body when the only newline is at index 0 (no empty truncated block)', async () => { + // A truncated file whose only newline is the leading byte: there is no + // complete line to keep, so cutting to the "last full line" would empty the + // body and leave the INTRO promising tasks that aren't there. The body must + // survive — guards cutToLastNewline against a `cut >= 0` regression that + // slices a position-0 newline down to "". + await writeProject('\n' + 'x'.repeat(LOOP_TASK_FILE_MAX_BYTES + 100)); + + const tick = await resolver.resolve('cron'); + + expect(tick.full).toBe(true); + const warning = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; + expect(tick.modelText).toContain(`\n${warning}`); + // The x-run above the warning is non-empty; a `cut >= 0` regression would + // empty it, leaving only INTRO + warning. + const beforeWarning = tick.modelText.slice( + 0, + tick.modelText.indexOf(`\n${warning}`), + ); + expect(beforeWarning).toContain('xxxxxxxxxx'); + }); + it('names the home loop.md in the header and re-expands when the source switches', async () => { await writeProject('- project tasks'); const first = await resolver.resolve('cron'); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index c0090792b8e..ebe450ee09d 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -58,14 +58,22 @@ const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE const INTRO = 'The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).'; -// Body of the unchanged-tick reminder — the H1 is supplied by tickHeading() so -// the full block and the short reminder share exactly one heading style. -const SHORT_REMINDER_BODY: Record = { - cron: 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', +// Mode-specific pacing guidance. Appended to BOTH the full block and the short +// reminder — the no-op/re-arm instruction applies on every tick. +const PACING_SUFFIX: Record = { + cron: 'The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', dynamic: - 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick. You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', + 'You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', }; +// Preamble for the UNCHANGED-tick reminder, which points back to the full block +// delivered on an earlier fire. NOT used on the first/changed full delivery, +// where the block is present in THIS message — there is no "earlier" to refer +// back to, so claiming the contents were established earlier would contradict +// the INTRO that sits right above them. +const SHORT_REMINDER_PREAMBLE = + 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick.'; + /** * The single H1 for every tick variant (full block, short reminder, absent), so * they share one heading style and the dynamic-pacing suffix lives in one place. @@ -117,6 +125,11 @@ export function detectLoopSentinel(prompt: string): LoopMode | null { /** Trim a truncated body back to its last full line before the warning tail. */ function cutToLastNewline(content: string): string { const cut = content.lastIndexOf('\n'); + // `> 0`, not `>= 0`: when the only newline is at index 0 (or there is none), + // there is no complete line to keep, so cutting would empty the body and leave + // the INTRO promising tasks that aren't there. Keep the (truncated) content + // instead — only a genuine trailing partial line (newline at index > 0) is + // dropped so the warning never glues onto a half-line. return cut > 0 ? content.slice(0, cut) : content; } @@ -178,15 +191,18 @@ export class LoopTickResolver { if (this.#lastContent === content) { return { - modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_BODY[mode]}`, + modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_PREAMBLE} ${PACING_SUFFIX[mode]}`, full: false, sourcePath: result.path, sourceLabel, }; } + // First/changed full delivery: INTRO + the block itself, then only the + // pacing suffix — no "established earlier" preamble, which would contradict + // the block sitting right here in this same message. return { - modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${SHORT_REMINDER_BODY[mode]}`, + modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${PACING_SUFFIX[mode]}`, full: true, sourcePath: result.path, sourceLabel, From c0a962af48f0f55c7fd821b6526af6e6d98733b1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 11:03:16 +0800 Subject: [PATCH 13/31] fix(loop): address CI review on loop.md reader/resolver - loop-task-file: at the byte cap, drop a still-incomplete trailing UTF-8 sequence (walk to the last lead byte; cut before it when its declared width runs past the cap) so a mid-/malformed multi-byte char can't leave an orphan lead that decodes to a trailing U+FFFD. The byte-length re-clamp is now a pure safety net rather than the load-bearing step. - loop-task-file/resolver: scope the boundary realpath cache to the LoopTickResolver instance (fresh per /cd rebuild, cleared by resetCache) so a long-lived process can't pin a stale boundary after a symlink re-point; direct barrel callers keep the process-lifetime fallback. Eviction-on-failure is preserved. - nonInteractiveCli: log the headless cleanup of a recurring session loop.md cron, and guard the discarded delete() promise with .catch() so a future async delete can't become an unhandled rejection. - tests: cover the incomplete-trailing-char cap, the ENAMETOOLONG skip branch, and resetCache invalidating the boundary realpath cache. Co-Authored-By: Qwen-Coder --- packages/cli/src/nonInteractiveCli.ts | 17 ++++- .../bundled/loop/loop-task-file.test.ts | 64 +++++++++++++++++ .../src/skills/bundled/loop/loop-task-file.ts | 71 ++++++++++++++----- .../bundled/loop/loop-tick-resolver.test.ts | 33 ++++++++- .../skills/bundled/loop/loop-tick-resolver.ts | 10 +++ 5 files changed, 175 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 09ce1face38..1e60557294f 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -161,10 +161,21 @@ export function skipHeadlessLoopSentinel( return false; } if (job.recurring && !job.durable) { + // A user created this recurring loop.md cron via /loop in interactive mode; + // deleting it here is otherwise silent, so leave a trace of why it vanished + // from `cron list` when the same workspace is later run headless. + debugLogger.debug( + 'skipHeadlessLoopSentinel: cleaning up recurring session loop.md cron in headless mode', + { jobId: job.id }, + ); // delete() removes the in-memory job synchronously before any await, so the - // sessionSize check that follows this call sees it gone; the returned - // promise has no on-disk work for a session job. Fire-and-forget. - void scheduler.delete(job.id); + // sessionSize check that follows this call sees it gone; the returned promise + // has no on-disk work for a session job. Fire-and-forget, but swallow a + // rejection so a future async delete() can't surface as an unhandled + // rejection (fatal under Node's --unhandled-rejections=throw). + void scheduler.delete(job.id).catch(() => { + /* session job: nothing to clean up on a delete failure */ + }); } return true; } diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 14e300d7d96..22c0230aa68 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -679,4 +679,68 @@ describe('readLoopTaskFile', () => { expect(result.content).not.toContain('�'); } }); + + it('drops an INCOMPLETE trailing multi-byte sequence at the cap (no orphan lead / U+FFFD)', async () => { + // A buffer cut mid-4-byte-sequence whose final byte is NOT a continuation + // (the sequence is incomplete) defeats the continuation-only back-off: it + // would keep `f0 9f a6` and decode a trailing U+FFFD. Sized so the orphan + // lands exactly on the cap, so the byte-length re-clamp can't mask it — only + // dropping the whole incomplete lead keeps the tail clean. + const head = Buffer.alloc(LOOP_TASK_FILE_MAX_BYTES - 3, 0x61); // 'a' * (cap-3) + const partial = Buffer.from([0xf0, 0x9f, 0xa6]); // 3 of a 4-byte char... + const tail = Buffer.from([0x62]); // ...then 'b' (non-continuation) → incomplete + const raw = Buffer.concat([head, partial, tail]); // cap + 1 bytes → truncated + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + // The incomplete sequence is gone entirely — no replacement char, and the + // body ends on the last complete ('a') char. + expect(result.content).not.toContain('�'); + expect(result.content.endsWith('a')).toBe(true); + } + }); + + it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => { + // The over-long-path code is in the skip whitelist but otherwise untested; a + // typo'd entry would start throwing on a real ENAMETOOLONG instead of falling + // through. Drive it via a mocked lstat on the project path; home still reads. + await writeHome('user tasks'); + const projectLoop = path.join(projectRoot, '.qwen', 'loop.md'); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const enametoolong = Object.assign(new Error('ENAMETOOLONG'), { + code: 'ENAMETOOLONG', + }); + vi.spyOn(fs, 'lstat').mockImplementation(async (p) => + String(p) === projectLoop + ? Promise.reject(enametoolong) + : actual.lstat(p as string), + ); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toMatchObject({ + status: 'found', + source: 'home', + content: 'user tasks', + }); + }); }); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 6f3b0c3f6c8..8c2e1b9923b 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -44,25 +44,39 @@ export interface ReadLoopTaskFileOptions { * trust-derived value explicitly. */ allowProjectFile?: boolean; + /** + * Per-resolver cache for the boundary `fs.realpath()` results. LoopTickResolver + * passes its own instance-scoped Map so the cache lifetime is tied to the + * resolver (rebuilt on `/cd`, cleared by `resetCache()`) instead of living + * forever at module scope. Omitted by direct barrel callers, who fall back to a + * process-lifetime cache. Eviction-on-failure is preserved either way. + */ + realDirCache?: Map>; } /** - * `fs.realpath(dir)` cache for the two confinement boundaries — the workspace - * root and the home dir. Each is stable for the process, so resolve it once per - * dir instead of every tick. Keyed by the TRUSTED dir the caller passes (never a - * path derived from file contents), so an external caller of this re-exported - * function can't widen a boundary with a stale/broader path. + * Process-lifetime fallback `fs.realpath(dir)` cache for the two confinement + * boundaries — the workspace root and the home dir. Used only by direct callers + * of this re-exported function that don't supply their own cache; resolver-driven + * ticks pass an instance-scoped cache (see `ReadLoopTaskFileOptions.realDirCache`) + * so the boundary realpath stays invalidatable and a long-lived process can't pin + * a stale boundary after a `/cd` or symlink re-point. Keyed by the TRUSTED dir the + * caller passes (never a path derived from file contents), so a caller can't widen + * a boundary with a stale/broader path. */ -const realDirCache = new Map>(); +const moduleRealDirCache = new Map>(); -function resolveRealDir(dir: string): Promise { - let real = realDirCache.get(dir); +function resolveRealDir( + dir: string, + cache: Map>, +): Promise { + let real = cache.get(dir); if (real === undefined) { real = fs.realpath(dir); // Don't pin a rejection: a transient failure (EACCES, ENOENT) must be // retried next tick rather than cached, preserving per-tick error semantics. - real.catch(() => realDirCache.delete(dir)); - realDirCache.set(dir, real); + real.catch(() => cache.delete(dir)); + cache.set(dir, real); } return real; } @@ -144,6 +158,7 @@ export async function readLoopTaskFile({ projectRoot, homeDir, allowProjectFile = false, + realDirCache = moduleRealDirCache, }: ReadLoopTaskFileOptions): Promise { if (!allowProjectFile) { // Repo-controlled file in an untrusted folder — never read it (the @@ -187,7 +202,7 @@ export async function readLoopTaskFile({ // A final-component lstat can't see an ANCESTOR symlink (e.g. a // checked-in `.qwen -> /outside`); realpath resolves it, so confine the // canonical path to the workspace root before reading. - const realRoot = await resolveRealDir(projectRoot); + const realRoot = await resolveRealDir(projectRoot, realDirCache); const real = await fs.realpath(filePath); if (!isWithin(realRoot, real)) { debugLogger.debug( @@ -214,7 +229,7 @@ export async function readLoopTaskFile({ // otherwise `~/.qwen/loop.md -> /etc/passwd` (or `-> /dev/...`) would be // read and fed to the model every tick. In-home dotfile symlinks (e.g. // `-> ~/dotfiles/loop.md`) still resolve inside $HOME and are allowed. - const realHome = await resolveRealDir(homeDir); + const realHome = await resolveRealDir(homeDir, realDirCache); const real = await fs.realpath(filePath); if (!isWithin(realHome, real)) { debugLogger.debug( @@ -258,14 +273,38 @@ export async function readLoopTaskFile({ const truncated = buffer.byteLength > LOOP_TASK_FILE_MAX_BYTES; let content: string; if (truncated) { - // Cap by bytes on a UTF-8 boundary: back off any trailing continuation - // bytes from a mid-character cut, then re-clamp the decoded string so - // malformed input (an orphan lead byte decoding to U+FFFD) still can't - // exceed the cap. + // Cap by bytes on a UTF-8 char boundary. First back off any trailing + // continuation bytes (10xxxxxx) left by a mid-character cut at the cap... let end = LOOP_TASK_FILE_MAX_BYTES; while (end > 0 && (buffer[end] & 0xc0) === 0x80) { end--; } + // ...then drop a still-INCOMPLETE trailing char: walk to the last lead byte + // and, if its declared width runs past `end` (a 4-byte `f0` with too few + // continuations, from a mid-sequence cut or malformed input), cut before it. + // The continuation walk alone leaves such an orphan lead, which decodes to a + // trailing U+FFFD the byte-length re-clamp below can keep — so this boundary + // fix is load-bearing and the re-clamp is a pure safety net. + let lead = end - 1; + while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) { + lead--; + } + if (lead >= 0) { + const b = buffer[lead]; + const width = + (b & 0x80) === 0x00 + ? 1 + : (b & 0xe0) === 0xc0 + ? 2 + : (b & 0xf0) === 0xe0 + ? 3 + : (b & 0xf8) === 0xf0 + ? 4 + : 1; // invalid lead (0xC0/0xC1/0xF8–0xFF): treat as a 1-byte unit + if (lead + width > end) { + end = lead; + } + } content = buffer.subarray(0, end).toString('utf8'); while (Buffer.byteLength(content, 'utf8') > LOOP_TASK_FILE_MAX_BYTES) { content = content.slice(0, -1); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index af24e49c822..359be4920bf 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOOP_SENTINEL_CRON, LOOP_SENTINEL_DYNAMIC, @@ -16,6 +16,14 @@ import { } from './loop-tick-resolver.js'; import { LOOP_TASK_FILE_MAX_BYTES } from './loop-task-file.js'; +// Make only realpath observable; every other fs call stays real so the temp-dir +// fixtures keep working. The default impl calls through, so behavior is unchanged +// — the spy just lets a test count how often a boundary is re-resolved. +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual(); + return { ...actual, realpath: vi.fn(actual.realpath) }; +}); + describe('detectLoopSentinel', () => { it('recognizes the cron and dynamic sentinels exactly (after trim)', () => { expect(detectLoopSentinel(LOOP_SENTINEL_CRON)).toBe('cron'); @@ -61,6 +69,8 @@ describe('LoopTickResolver', () => { }); afterEach(async () => { + // Reset realpath call history (keep the call-through impl) between tests. + vi.mocked(fs.realpath).mockClear(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -201,6 +211,27 @@ describe('LoopTickResolver', () => { expect(tick.modelText).toContain('- stable'); }); + it('clears the boundary realpath cache on resetCache so it is re-resolved', async () => { + // The fs.realpath of the confinement boundary (projectRoot) is cached per + // resolver for the per-tick perf win. resetCache must invalidate it too — + // otherwise a long-lived process keeps a stale boundary after a /cd or symlink + // re-point. Prove projectRoot is re-resolved only after a reset. + await writeProject('- tasks'); + const rootResolves = () => + vi + .mocked(fs.realpath) + .mock.calls.filter((c) => String(c[0]) === projectRoot).length; + + await resolver.resolve('cron'); + expect(rootResolves()).toBe(1); + await resolver.resolve('cron'); + expect(rootResolves()).toBe(1); // served from the instance cache, not re-resolved + + resolver.resetCache(); + await resolver.resolve('cron'); + expect(rootResolves()).toBe(2); // cache cleared → boundary re-resolved + }); + it('emits the absent reminder without poisoning the cache, then re-expands on recreate', async () => { const absent = await resolver.resolve('dynamic'); expect(absent.full).toBe(false); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index ebe450ee09d..b65aeb1ce31 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -141,6 +141,12 @@ export class LoopTickResolver { // is aborted between resolve() and delivery can't poison the cache into // sending a dangling short reminder next time. #pendingContent: string | null = null; + // Instance-scoped fs.realpath cache for the confinement boundaries, handed to + // readLoopTaskFile. Tying it to the resolver (a fresh Map per /cd rebuild, + // cleared by resetCache) keeps the per-tick perf win while staying + // invalidatable — a module-global cache would pin a stale boundary in a + // long-lived process after a /cd or symlink re-point. + readonly #realDirCache = new Map>(); constructor(private readonly deps: LoopTickResolverDeps) {} @@ -149,6 +155,9 @@ export class LoopTickResolver { resetCache(): void { this.#lastContent = null; this.#pendingContent = null; + // A reset may follow a /cd or symlink change, so drop the cached boundary + // realpaths too and re-resolve them on the next tick. + this.#realDirCache.clear(); } /** Commit the last resolve()'s content once it has reached the model. */ @@ -163,6 +172,7 @@ export class LoopTickResolver { projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, allowProjectFile: this.deps.allowProjectFile, + realDirCache: this.#realDirCache, }); if (result.status === 'missing') { From d0561f04d414b7943bcf84339087f77f66960a45 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 14:03:29 +0800 Subject: [PATCH 14/31] fix(loop): re-evaluate folder trust per tick; guard durable loop.md in headless Address review on the loop.md reader/resolver: - Session resolver takes a folder-trust getter and re-reads it on every tick instead of capturing it once at construction. isTrustedFolder() is not process-stable in IDE sessions (a workspace-trust update can flip it), so a trusted->untrusted flip now stops reading the repo-controlled .qwen/loop.md immediately rather than serving it from a stale snapshot. - Headless durable cron: a durable <> sentinel cannot be expanded in a headless run, so the scheduler skips such durable jobs before processJob stamps lastFiredAt (new setSkipDurableFire guard, honored by the tick loop and the catch-up/final delivery paths). Previously the tick was marked fired and persisted while the work was skipped downstream, silently consuming a tick the owning interactive session should run. - Drop the absolute sourcePath from LoopTickResult; sourceLabel already serves the UI label and the presence check without leaking an absolute path. - Size the loop.md read buffer to the file (stat.size + 1) instead of always allocating the 25 KB cap, keeping the bounded-read and truncated-flag semantics intact. - Add one happy-path debug log on a successful loop.md read (source label, byte count, truncated flag) so oncall can confirm pickup. - Add load-bearing tests for the 2-byte and 3-byte UTF-8 width branches. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 2 +- .../src/acp-integration/session/Session.ts | 16 ++--- packages/cli/src/nonInteractiveCli.ts | 20 ++++++- .../core/src/services/cronScheduler.test.ts | 31 ++++++++++ packages/core/src/services/cronScheduler.ts | 35 ++++++++++- .../bundled/loop/loop-task-file.test.ts | 59 +++++++++++++++++++ .../src/skills/bundled/loop/loop-task-file.ts | 27 +++++++-- .../bundled/loop/loop-tick-resolver.test.ts | 57 ++++++++++++++---- .../skills/bundled/loop/loop-tick-resolver.ts | 28 +++++---- 9 files changed, 232 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 146175242ee..06ad003282b 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4614,7 +4614,7 @@ describe('Session', () => { }); it('echoes the absent label when a sentinel fires with no loop.md present', async () => { - // The `loopTick && !loopTick.sourcePath` branch: a sentinel fires but no + // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'loop-md-absent-'), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9dc3c3682ce..b904d63f83a 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2470,9 +2470,11 @@ export class Session implements SessionContext { // The project `.qwen/loop.md` is repo-controlled, so an untrusted folder // must not read it and feed it to the model (mirrors getProjectHooks()'s // trust gate). The home/global `~/.qwen/loop.md` is user-owned and stays - // allowed. Folder trust is process-stable (a change restarts the CLI), - // so capturing it at construction is sufficient. - allowProjectFile: this.config.isTrustedFolder(), + // allowed. Pass a getter, not a snapshot: isTrustedFolder() can flip + // mid-session on an IDE workspace-trust update, and the resolver outlives + // a single tick — re-read it on every resolve() so a trusted→untrusted + // flip stops reading the project file immediately. + allowProjectFile: () => this.config.isTrustedFolder(), }); this.loopTickResolverRoot = root; } @@ -2526,16 +2528,16 @@ export class Session implements SessionContext { `loop tick: mode=${loopMode} delivery=${ loopTick.full ? 'full' - : loopTick.sourcePath + : loopTick.sourceLabel ? 'reminder' : 'absent' } source=${loopTick.sourceLabel ?? 'none'}`, ); } // For a loop tick echo a stable, relative label — never the bare - // sentinel, the full task dump, or the absolute sourcePath (which - // would leak the OS username / dir layout into the ACP client UI); - // otherwise echo the prompt verbatim. + // sentinel or the full task dump (and the resolver never hands back + // the absolute path, which would leak the OS username / dir layout + // into the ACP client UI); otherwise echo the prompt verbatim. const echoText = loopTick ? loopTick.sourceLabel ? `Loop tick — tasks from ${loopTick.sourceLabel}` diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1e60557294f..b85ffc53f43 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -149,9 +149,14 @@ function formatLoopDetectedMessage(loopType: LoopType | undefined): string { * A recurring SESSION (non-durable) loop.md job would otherwise stay in * `scheduler.sessionSize` and re-fire every interval, pinning the headless run * open forever (the hold-open resolves only when sessionSize hits zero); delete - * it so the run can terminate. Durable jobs are left untouched — they persist - * for a future owning session and never count toward sessionSize — and a - * one-shot job is already removed before it fires. + * it so the run can terminate. Durable jobs are left untouched here — they + * persist for a future owning session and never count toward sessionSize — and + * a one-shot job is already removed before it fires. + * + * Note: a DURABLE loop.md sentinel never even reaches this callback in headless, + * because `setSkipDurableFire` filters it at the scheduler before any fire or + * lastFiredAt persist (otherwise the tick would be marked fired while the work + * is skipped — silent loss). This guard's durable branch is kept defensive. */ export function skipHeadlessLoopSentinel( scheduler: CronScheduler, @@ -1581,6 +1586,15 @@ export async function runNonInteractive( : config.getCronScheduler(); if (scheduler) { + // A headless run can't expand a `<>` sentinel, so durable + // loop.md jobs must be skipped at the scheduler level — firing one + // here would stamp+persist its lastFiredAt while the work is skipped + // (see skipHeadlessLoopSentinel), silently consuming a tick the + // owning interactive session should run. Set BEFORE enableDurable so + // a buffered catch-up flush at start() honors it too. + scheduler.setSkipDurableFire( + (job) => detectLoopSentinel(job.prompt) !== null, + ); // Durable tasks live under ~/.qwen (user-owned, not in the // working tree), so no folder-trust gate is needed here. await scheduler diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index ca7a0b44937..06465bb87b3 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -932,6 +932,37 @@ describe('CronScheduler', () => { }); }); + it('skips a durable job the consumer cannot run: no fire, lastFiredAt left untouched', async () => { + // A headless run can't expand a `<>` sentinel. Firing it would + // stamp + persist lastFiredAt while the work is skipped downstream, + // silently consuming the tick; setSkipDurableFire must leave such a job's + // schedule intact for the owning interactive session. A co-scheduled + // non-sentinel durable job proves the skip is selective AND lands its + // persist in the SAME tick write — so checking the sentinel stayed null + // once the sibling shows its stamp is race-free, not a timing gap. + await writeCronTasks(tmpDir, [ + { ...diskTask('loopmd'), prompt: '<>' }, + { ...diskTask('normal'), prompt: 'normal task' }, + ]); + await scheduler.enableDurable('session-1'); + scheduler.setSkipDurableFire((job) => job.prompt === '<>'); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.tick(new Date(2025, 0, 15, 10, 30, 59)); + + expect(fired.map((j) => j.prompt)).toEqual(['normal task']); + + const minuteMs = new Date(2025, 0, 15, 10, 30, 0).getTime(); + await vi.waitFor(async () => { + const byId = Object.fromEntries( + (await readCronTasks(tmpDir)).map((t) => [t.id, t]), + ); + expect(byId['normal']!.lastFiredAt).toBe(minuteMs); // fired → persisted + expect(byId['loopmd']!.lastFiredAt ?? null).toBeNull(); // skipped → untouched + }); + }); + it('rolls back the in-memory job when the durable persist fails', async () => { // A corrupted tasks file makes updateCronTasks throw inside // addCronTask, after the job was provisionally installed in memory. diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 48286fd058f..9f777c79298 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -200,6 +200,14 @@ export class CronScheduler { private _disabled = false; private timer: ReturnType | null = null; private onFire: ((job: CronJob) => void) | null = null; + // Guard a consumer installs when it cannot execute certain durable jobs. A + // headless run can't expand a `.qwen/loop.md` sentinel, so it marks such + // durable jobs skippable here: they are then neither fired NOR have their + // persisted fired-state advanced (lastFiredAt stamp / one-shot removal), + // leaving the tick for the owning interactive session instead of silently + // consuming it for work the consumer never ran. Session-only jobs and durable + // jobs in a consumer that can run them are unaffected (predicate unset/false). + private skipDurableFire: ((job: CronJob) => boolean) | null = null; // --- Durable (file-backed) support --- private durableEnabled = false; @@ -755,6 +763,10 @@ export class CronScheduler { for (const id of pending.ids) { const job = this.jobs.get(id); if (!job) continue; // deleted while buffered + // Same skip as the tick loop: a durable job this consumer can't run + // is not fired and not stamped (left out of persistCatchUpStamps), + // so its overdue schedule survives for the owning session. + if (this.skipDurableFire?.(job)) continue; onFire(job); fired.push(id); } @@ -762,10 +774,15 @@ export class CronScheduler { break; } case 'final': { + const fired: string[] = []; for (const job of pending.jobs) { + // A skipped durable job is left on disk (not in removeMissedFromDisk) + // so the owning session still gets its one final fire + delete. + if (this.skipDurableFire?.(job)) continue; onFire(job); + fired.push(job.id); } - this.removeMissedFromDisk(pending.jobs.map((j) => j.id)); + this.removeMissedFromDisk(fired); break; } default: { @@ -860,6 +877,17 @@ export class CronScheduler { } } + /** + * Installs a predicate marking durable jobs the active consumer cannot run + * (see the `skipDurableFire` field). Such jobs are skipped before any fire or + * persist, so their durable schedule is left intact for an owning session that + * can run them. Set before `start()` so a buffered catch-up flush also honors + * it. A no-op for session-only jobs. + */ + setSkipDurableFire(predicate: (job: CronJob) => boolean): void { + this.skipDurableFire = predicate; + } + /** * Starts the scheduler tick. Calls `onFire` when a job is due. * Only fires when called — does not auto-fire missed intervals. @@ -1005,6 +1033,11 @@ export class CronScheduler { // in non-owner sessions, where a persisted job would otherwise fire // uncoordinated alongside the real owner's copy. if (job.durable && !this.isOwner) continue; + // A durable job this consumer can't run (e.g. a loop.md sentinel in a + // headless run) is skipped BEFORE processJob stamps lastFiredAt — firing + // it here would persist the stamp while the work is skipped downstream, + // silently consuming the tick. Leave it for the owning session. + if (job.durable && this.skipDurableFire?.(job)) continue; const result = this.processJob(job, currentDate, currentMs); if (!job.durable || result === 'none') continue; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 22c0230aa68..d90401b4153 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -640,6 +640,10 @@ describe('readLoopTaskFile', () => { for (const length of readLengths) { expect(length).toBeLessThanOrEqual(cap); } + // Load-bearing: the buffer is sized to the file (+1 for truncation + // detection), NOT the 25 KB cap — so a tiny loop.md doesn't zero-fill 25 KB + // every tick. The first read requests exactly that bounded length. + expect(readLengths[0]).toBe(body.length + 1); }); it('does not truncate task files at exactly the byte cap', async () => { @@ -712,6 +716,61 @@ describe('readLoopTaskFile', () => { } }); + it('drops an INCOMPLETE trailing 2-byte lead at the cap (covers the 2-byte width branch)', async () => { + // A lone 2-byte lead (0xc3, its continuation replaced by a non-continuation) + // must be dropped by the width branch ((b & 0xe0) === 0xc0 → width 2), not + // kept as an orphan decoding to U+FFFD. The two trailing continuation bytes + // are sized so a width-table regression (treating 0xc3 as width 1) leaves + // the orphan's U+FFFD at exactly the cap, where the byte-length re-clamp + // can't mask it — catching a regression the re-clamp alone would hide. + const N = LOOP_TASK_FILE_MAX_BYTES; + const head = Buffer.alloc(N - 3, 0x61); // 'a' * (N-3) + const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80]); // 2-byte lead, 'A', 2 conts + const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 3)); + } + }); + + it('drops an INCOMPLETE trailing 3-byte lead at the cap (covers the 3-byte width branch)', async () => { + // A 3-byte lead with only ONE of its two continuations (0xe4 0xb8) followed + // by a non-continuation must be dropped by the width branch + // ((b & 0xf0) === 0xe0 → width 3). Sized so a width-table regression (0xe4 + // treated as width 1 or 2) leaves the orphan's U+FFFD below the cap, where + // it survives the re-clamp — so the regression is observable. + const N = LOOP_TASK_FILE_MAX_BYTES; + const head = Buffer.alloc(N - 4, 0x61); // 'a' * (N-4) + const tail = Buffer.from([0xe4, 0xb8, 0x41, 0x80, 0x80]); // lead+1 cont, 'A', 2 conts + const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 4)); + } + }); + it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => { // The over-long-path code is in the skip whitelist but otherwise untested; a // typo'd entry would start throwing on a real ENAMETOOLONG instead of falling diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index 8c2e1b9923b..f760dfd7de5 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -106,18 +106,25 @@ function isWithin(root: string, real: string): boolean { async function readBoundedTaskFile(filePath: string): Promise { const handle = await fs.open(filePath, 'r'); try { - if (!(await handle.stat()).isFile()) { + const stat = await handle.stat(); + if (!stat.isFile()) { return null; } const cap = LOOP_TASK_FILE_MAX_BYTES + 1; - const buffer = Buffer.alloc(cap); + // Size the buffer to the file (+1 to still detect a file that exceeds the + // cap), never above cap — so a small loop.md doesn't zero-fill 25 KB every + // tick. `read` below is bounded by this length too, so a file that grows + // past `stat.size` between stat and read is still read safely (its tail just + // isn't seen this tick). + const allocSize = Math.min(cap, stat.size + 1); + const buffer = Buffer.alloc(allocSize); let total = 0; - // A single read() may return short even before EOF; loop until cap or EOF. - while (total < cap) { + // A single read() may return short even before EOF; loop until full or EOF. + while (total < allocSize) { const { bytesRead } = await handle.read( buffer, total, - cap - total, + allocSize - total, total, ); if (bytesRead === 0) { @@ -313,6 +320,16 @@ export async function readLoopTaskFile({ content = buffer.toString('utf8'); } + // The one happy-path trace (all other logs here are skip/failure) so oncall + // can confirm a tick actually picked up a file. Logs the relative source + // label and byte count, never the absolute path (which would leak the OS + // username / dir layout into debug logs). + debugLogger.debug('read loop.md', { + source, + bytes: buffer.byteLength, + truncated, + }); + return { status: 'found', path: filePath, diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 359be4920bf..4eba5895ba6 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -64,7 +64,7 @@ describe('LoopTickResolver', () => { resolver = new LoopTickResolver({ projectRoot, homeDir, - allowProjectFile: true, + allowProjectFile: () => true, }); }); @@ -82,13 +82,12 @@ describe('LoopTickResolver', () => { const untrusted = new LoopTickResolver({ projectRoot, homeDir, - allowProjectFile: false, + allowProjectFile: () => false, }); const tick = await untrusted.resolve('cron'); expect(tick.full).toBe(true); - expect(tick.sourcePath).toBe(homeFile()); expect(tick.sourceLabel).toBe('home loop.md'); expect(tick.modelText).toContain('- user tasks'); expect(tick.modelText).not.toContain('- repo-controlled tasks'); @@ -99,24 +98,56 @@ describe('LoopTickResolver', () => { const untrusted = new LoopTickResolver({ projectRoot, homeDir, - allowProjectFile: false, + allowProjectFile: () => false, }); const tick = await untrusted.resolve('cron'); expect(tick.full).toBe(false); - expect(tick.sourcePath).toBeUndefined(); + expect(tick.sourceLabel).toBeUndefined(); expect(tick.modelText).toContain('loop.md is not currently present'); }); + it('re-reads folder trust per tick: a trusted→untrusted flip stops reading the project file', async () => { + // allowProjectFile is a getter, not a snapshot: isTrustedFolder() can flip + // mid-session (IDE workspace-trust update) and the resolver outlives a tick. + // A resolver built while trusted must skip the repo-controlled project + // loop.md on the very next tick once trust flips — not keep reading it. + await writeProject('- repo-controlled tasks'); + let trusted = true; + const flipping = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: () => trusted, + }); + + const trustedTick = await flipping.resolve('cron'); + expect(trustedTick.full).toBe(true); + expect(trustedTick.sourceLabel).toBe('project loop.md'); + expect(trustedTick.modelText).toContain('- repo-controlled tasks'); + flipping.markDelivered(); + + // Trust revoked. With no user-owned home loop.md, the next tick must be a + // labelled no-op — the project file is no longer read by the SAME resolver. + trusted = false; + const untrustedTick = await flipping.resolve('cron'); + expect(untrustedTick.full).toBe(false); + expect(untrustedTick.sourceLabel).toBeUndefined(); + expect(untrustedTick.modelText).toContain( + 'loop.md is not currently present', + ); + expect(untrustedTick.modelText).not.toContain('- repo-controlled tasks'); + }); + it('delivers the full task block on first fire', async () => { await writeProject('- ship the thing'); const tick = await resolver.resolve('dynamic'); expect(tick.full).toBe(true); - // sourcePath keeps the absolute path for local UI; the model text must not. - expect(tick.sourcePath).toBe(projectFile()); + // sourceLabel is the relative label, never the absolute path — the model + // text (and this label) must not leak projectFile(). + expect(tick.sourceLabel).toBe('project loop.md'); expect(tick.modelText).toContain( '# /loop tick — loop.md tasks from project loop.md', ); @@ -143,7 +174,7 @@ describe('LoopTickResolver', () => { expect(tick.full).toBe(false); // The unchanged branch still reports the resolved source so Session.ts can // label it even when only the short reminder is sent. - expect(tick.sourcePath).toBe(projectFile()); + expect(tick.sourceLabel).toBe('project loop.md'); expect(tick.modelText).not.toContain( 'The user configured a loop-tasks file.', ); @@ -235,7 +266,7 @@ describe('LoopTickResolver', () => { it('emits the absent reminder without poisoning the cache, then re-expands on recreate', async () => { const absent = await resolver.resolve('dynamic'); expect(absent.full).toBe(false); - expect(absent.sourcePath).toBeUndefined(); + expect(absent.sourceLabel).toBeUndefined(); expect(absent.modelText).toContain('loop.md is not currently present'); await writeProject('- recreated tasks'); @@ -252,7 +283,7 @@ describe('LoopTickResolver', () => { const dyn = new LoopTickResolver({ projectRoot, homeDir, - allowProjectFile: true, + allowProjectFile: () => true, }); const dynTick = await dyn.resolve('dynamic'); expect(dynTick.modelText).toContain( @@ -294,7 +325,7 @@ describe('LoopTickResolver', () => { const dyn = new LoopTickResolver({ projectRoot, homeDir, - allowProjectFile: true, + allowProjectFile: () => true, }); const dynTick = await dyn.resolve('dynamic'); expect(dynTick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); @@ -350,7 +381,7 @@ describe('LoopTickResolver', () => { const first = await resolver.resolve('cron'); resolver.markDelivered(); expect(first.full).toBe(true); - expect(first.sourcePath).toBe(projectFile()); + expect(first.sourceLabel).toBe('project loop.md'); // Project gone, home has DIFFERENT content → re-expand (cache keys on // content, not path) and the header now names the home file. @@ -359,7 +390,7 @@ describe('LoopTickResolver', () => { const second = await resolver.resolve('cron'); expect(second.full).toBe(true); - expect(second.sourcePath).toBe(homeFile()); + expect(second.sourceLabel).toBe('home loop.md'); expect(second.modelText).toContain( '# /loop tick — loop.md tasks from home loop.md', ); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index b65aeb1ce31..cbef6cf0e55 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -35,10 +35,13 @@ export interface LoopTickResolverDeps { projectRoot: string; homeDir: string; /** - * Pass `config.isTrustedFolder()`. When false, the repo-controlled project - * `.qwen/loop.md` is not read (the user-owned `~/.qwen/loop.md` still is). + * Pass `() => config.isTrustedFolder()`. Re-evaluated on every `resolve()`, + * never captured once: `isTrustedFolder()` is not process-stable in IDE + * sessions (a workspace-trust update can flip it), and a trusted→untrusted + * flip must immediately stop reading the repo-controlled project + * `.qwen/loop.md` (the user-owned `~/.qwen/loop.md` still is read). */ - allowProjectFile: boolean; + allowProjectFile: () => boolean; } export interface LoopTickResult { @@ -46,10 +49,9 @@ export interface LoopTickResult { modelText: string; /** True when the full task block was delivered (vs a short reminder). */ full: boolean; - /** Resolved loop.md path, when present — for a clean user-facing label. */ - sourcePath?: string; /** Non-absolute label for the matched candidate (e.g. "project loop.md"), - * when present — safe for logs/UI that must not leak the absolute path. */ + * when present — safe for logs/UI that must not leak the absolute path, and + * doubles as the "a loop.md was found" flag for callers. */ sourceLabel?: string; } @@ -171,7 +173,9 @@ export class LoopTickResolver { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, - allowProjectFile: this.deps.allowProjectFile, + // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a + // resolver built while trusted must skip the project file once trust flips. + allowProjectFile: this.deps.allowProjectFile(), realDirCache: this.#realDirCache, }); @@ -193,17 +197,16 @@ export class LoopTickResolver { : result.content; this.#pendingContent = content; - // Label by which candidate matched, not result.path (the absolute path) — - // the absolute path would leak the OS username / dir layout to the API - // provider, and to debug logs. It still reaches the caller via sourcePath - // for local UI use. + // Label by which candidate matched, never result.path (the absolute path), + // which would leak the OS username / dir layout to the API provider and to + // debug logs. The label alone is enough for the caller's UI and presence + // check, so the absolute path is not surfaced on the result at all. const sourceLabel = SOURCE_LABELS[result.source]; if (this.#lastContent === content) { return { modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_PREAMBLE} ${PACING_SUFFIX[mode]}`, full: false, - sourcePath: result.path, sourceLabel, }; } @@ -214,7 +217,6 @@ export class LoopTickResolver { return { modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${PACING_SUFFIX[mode]}`, full: true, - sourcePath: result.path, sourceLabel, }; } From 565398e1f7c043b83318d2ff26257535890f45a4 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 15:55:52 +0800 Subject: [PATCH 15/31] fix(loop): honor skipDurableFire in deliverPending missed branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless durable-cron guard skipped fire+persist for catch-up and final deliveries but not the missed branch: a missed one-shot <> sentinel was unconditionally notified AND removed from disk, permanently losing the task though no consumer ran the loop.md work. Partition the missed batch on the same predicate — skipped one-shots are neither notified nor deleted (left on disk for the owning interactive session), while co-missed runnable one-shots still fire (batched) and are removed. Tests: - cronScheduler.test.ts: cover deliverPending missed/catch-up/final with a skip predicate — sentinel not fired/persisted, sibling fired+persisted; the missed-branch test mutation-locks the fix above. - loop-task-file.test.ts: isWithin sibling-prefix confinement — root /ws/foo must refuse a realpath under sibling /ws/foobar; fails if isWithin loses the trailing path.sep (bare startsWith). Co-Authored-By: Qwen-Coder --- .../core/src/services/cronScheduler.test.ts | 131 ++++++++++++++++++ packages/core/src/services/cronScheduler.ts | 22 ++- .../bundled/loop/loop-task-file.test.ts | 32 +++++ 3 files changed, 179 insertions(+), 6 deletions(-) diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index 06465bb87b3..d4bac5cb4cc 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -963,6 +963,137 @@ describe('CronScheduler', () => { }); }); + it('deliverPending missed branch: skips a sentinel one-shot (no fire, left on disk), fires a sibling', async () => { + // CRITICAL regression lock. A missed durable <> sentinel a + // headless consumer can't run must NOT be fired NOR removed from disk — + // deleting it would lose the task forever though no consumer ran the + // loop.md work. The skip is selective: a co-missed non-sentinel one-shot + // in the SAME batch is still fired (batched notice) and removed. + // Mutation check: revert the missed-branch partition and this fails + // (sentinel gets batched into the notice AND deleted from disk). + // Past createdAt so each one-shot's single fire already elapsed (missed). + const past = Date.now() - 10 * 60_000; + await writeCronTasks(tmpDir, [ + { + id: 'loopmd', + cron: '* * * * *', + prompt: '<>', + recurring: false, + createdAt: past, + lastFiredAt: null, + }, + { + id: 'normal', + cron: '* * * * *', + prompt: 'normal one-shot', + recurring: false, + createdAt: past, + lastFiredAt: null, + }, + ]); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.setSkipDurableFire((job) => job.prompt === '<>'); + await scheduler.enableDurable('session-1'); + + // Only the runnable sibling is notified; the sentinel is partitioned out. + expect(fired).toHaveLength(1); + expect(fired[0]!.missed).toBe(true); + expect(fired[0]!.prompt).toContain('normal one-shot'); + expect(fired[0]!.prompt).not.toContain('<>'); + + // The sentinel survives on disk; only the fired sibling is removed. + await vi.waitFor(async () => { + expect((await readCronTasks(tmpDir)).map((t) => t.id)).toEqual([ + 'loopmd', + ]); + }); + }); + + it('deliverPending catch-up branch: skips a sentinel overdue-recurring (stamp left on disk), fires a sibling', async () => { + // 3h overdue, past any jitter window. The sentinel must not be fired and + // must keep its on-disk lastFiredAt (left out of persistCatchUpStamps) so + // the owning session re-detects the catch-up; the sibling fires raw and + // its advanced stamp persists. + const createdAt = Date.now() - 3 * 60 * 60_000; + await writeCronTasks(tmpDir, [ + { + id: 'loopmd-c', + cron: '0 * * * *', + prompt: '<>', + recurring: true, + createdAt, + lastFiredAt: createdAt, + }, + { + id: 'normal-c', + cron: '0 * * * *', + prompt: 'overdue recurring', + recurring: true, + createdAt, + lastFiredAt: createdAt, + }, + ]); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.setSkipDurableFire((job) => job.prompt === '<>'); + await scheduler.enableDurable('session-1'); + + expect(fired.map((j) => j.prompt)).toEqual(['overdue recurring']); + + // Sibling's catch-up stamp lands; once it does, the sentinel's untouched + // disk stamp is race-free, not a timing gap. Both stay on disk. + await vi.waitFor(async () => { + const byId = Object.fromEntries( + (await readCronTasks(tmpDir)).map((t) => [t.id, t]), + ); + expect(byId['normal-c']!.lastFiredAt).toBeGreaterThan(createdAt); + expect(byId['loopmd-c']!.lastFiredAt).toBe(createdAt); + }); + }); + + it('deliverPending final branch: skips a sentinel aged-recurring (no final fire, left on disk), fires a sibling', async () => { + // Aged past the 7-day max age → final raw fire + delete. The sentinel is + // left on disk (not in removeMissedFromDisk) for the owning session; the + // sibling gets its one final fire and is deleted. + const createdAt = Date.now() - 8 * 24 * 60 * 60_000; + const lastFiredAt = Date.now() - 2 * 60 * 60_000; + await writeCronTasks(tmpDir, [ + { + id: 'loopmd-f', + cron: '0 * * * *', + prompt: '<>', + recurring: true, + createdAt, + lastFiredAt, + }, + { + id: 'normal-f', + cron: '0 * * * *', + prompt: 'aged recurring', + recurring: true, + createdAt, + lastFiredAt, + }, + ]); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.setSkipDurableFire((job) => job.prompt === '<>'); + await scheduler.enableDurable('session-1'); + + expect(fired.map((j) => j.prompt)).toEqual(['aged recurring']); + + // The fired sibling is deleted; the skipped sentinel stays on disk. + await vi.waitFor(async () => { + expect((await readCronTasks(tmpDir)).map((t) => t.id)).toEqual([ + 'loopmd-f', + ]); + }); + }); + it('rolls back the in-memory job when the durable persist fails', async () => { // A corrupted tasks file makes updateCronTasks throw inside // addCronTask, after the job was provisionally installed in memory. diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 9f777c79298..951819275ad 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -750,12 +750,22 @@ export class CronScheduler { // load (claw-code parity) — one model turn and one confirmation // flow instead of N separate prompts. The carrier job exists to // satisfy the onFire shape; consumers only read prompt/missed. - onFire({ - ...durableTaskToJob(pending.tasks[0]!), - prompt: buildMissedCronNotification(pending.tasks), - missed: true, - }); - this.removeMissedFromDisk(pending.tasks.map((t) => t.id)); + // Same skip as catch-up/final: partition out durable one-shots + // this consumer can't run (e.g. a loop.md sentinel in a headless + // run). They are not notified and, critically, left on disk (not + // in removeMissedFromDisk) so the owning interactive session still + // surfaces and runs them instead of losing the task permanently. + const runnable = pending.tasks.filter( + (t) => !this.skipDurableFire?.(durableTaskToJob(t)), + ); + if (runnable.length > 0) { + onFire({ + ...durableTaskToJob(runnable[0]!), + prompt: buildMissedCronNotification(runnable), + missed: true, + }); + this.removeMissedFromDisk(runnable.map((t) => t.id)); + } break; } case 'catch-up': { diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index d90401b4153..54cf03bece1 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -161,6 +161,38 @@ describe('readLoopTaskFile', () => { }); }); + it('refuses a project loop.md resolving to a SIBLING dir that shares a name prefix', async () => { + // isWithin appends path.sep before startsWith, so root `/foo` must NOT + // accept a candidate under the sibling `/foobar`. Make projectRoot + // `/foo` and symlink its `.qwen` to `/foobar/.qwen`; realpath then + // resolves loop.md into `foobar`, whose canonical path bare-startsWith + // `/foo` yet is NOT a descendant. A regression to a bare + // `real.startsWith(root)` (no separator) would wave this cross-workspace + // read through — this test fails the moment that separator is dropped. + const fooRoot = path.join(tempDir, 'foo'); + const siblingQwen = path.join(tempDir, 'foobar', '.qwen'); + await fs.mkdir(fooRoot, { recursive: true }); + await fs.mkdir(siblingQwen, { recursive: true }); + await fs.writeFile(path.join(siblingQwen, 'loop.md'), 'sibling tasks'); + await fs.symlink(siblingQwen, path.join(fooRoot, '.qwen')); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ + projectRoot: fooRoot, + homeDir, + allowProjectFile: true, + }); + + // Refused → falls through to home; the sibling content is never returned. + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'user tasks', + truncated: false, + }); + }); + it('does not read a project loop.md symlinked to an in-workspace file (exfiltration guard)', async () => { // The dangerous case confinement alone misses: a repo-committed // `.qwen/loop.md -> ../.env` resolves INSIDE the workspace, so the realpath From e77e66ee74755c9d9197c15fe1b6f51ea021f217 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 18:07:17 +0800 Subject: [PATCH 16/31] fix(loop): address loop.md injection review (durable guards, logs, QWEN_HOME) Address 5 review suggestions on the loop.md sentinel pipeline: - cronScheduler.deliverPending: align the catch-up and final skip guards with the tick loop by adding the `job.durable &&` predicate, so a non-durable job can never be skipped by skipDurableFire. - cronScheduler: add a debugLogger.debug at each of the 4 durable-skip points (tick/missed/catch-up/final) so a deferred durable loop.md tick is diagnosable. - Session: tag a loop.md sentinel-resolution failure distinctly from a model-call failure (sentinel mode + error code, no absolute path) before the shared cron catch surfaces it. - Session.test: add a tick test that delivers the full block then a SHORT REMINDER on an unchanged second tick (full:false + sourceLabel). - loop-task-file / Session: resolve the home/global loop.md from the QWEN_HOME-aware global dir (Storage.getGlobalQwenDir) instead of raw os.homedir(), keeping the home confinement from the earlier fix. Adds reader + Session coverage. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 176 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 37 +++- packages/core/src/services/cronScheduler.ts | 45 +++-- .../bundled/loop/loop-task-file.test.ts | 51 +++++ .../src/skills/bundled/loop/loop-task-file.ts | 28 ++- .../skills/bundled/loop/loop-tick-resolver.ts | 7 + 6 files changed, 324 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 06ad003282b..e10dd07b169 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4530,6 +4530,96 @@ describe('Session', () => { } }); + it('delivers the full block then a SHORT REMINDER on an unchanged second tick', async () => { + // Two ticks of the same sentinel over unchanged loop.md: tick1 delivers + // the FULL block (INTRO + task body) and commits it; tick2 sees the + // unchanged content and delivers the one-line SHORT REMINDER (full:false) + // — a pure pointer with neither the INTRO nor the body. The client echo + // still names the source on the reminder (sourceLabel set), so this pins + // the full:false/labelled-reminder path through BOTH the echo and the + // model-message paths. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-reminder-'), + ); + const loopMdPath = path.join(tmpDir, '.qwen', 'loop.md'); + await fs.mkdir(path.dirname(loopMdPath), { recursive: true }); + await fs.writeFile(loopMdPath, '- finish the migration'); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + // Drained serially against the one persistent resolver, so tick2 + // sees tick1's committed content as unchanged. + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const cronModelTexts = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .filter((c) => Array.isArray(c[1]?.message)) + .map((c) => + (c[1].message as Array<{ text?: string }>) + .map((p) => p.text ?? '') + .join(''), + ); + + await vi.waitFor(() => { + const texts = cronModelTexts(); + // Exactly one FULL delivery (INTRO) and one SHORT REMINDER (preamble). + const full = texts.filter((t) => + t.includes('The user configured a loop-tasks file.'), + ); + const reminder = texts.filter((t) => + t.includes( + 'Work the tasks from the loop.md contents established earlier', + ), + ); + expect(full).toHaveLength(1); + expect(reminder).toHaveLength(1); + // The reminder is a pointer only: no INTRO and no task body (which + // the full block already paid into the cached prefix). + expect(reminder[0]).not.toContain( + 'The user configured a loop-tasks file.', + ); + expect(reminder[0]).not.toContain('- finish the migration'); + }); + + // full:false reminder still resolves a sourceLabel, so its client echo + // names the source — identical to the full tick's echo (both ticks). + const labelledEchoes = ( + mockClient.sessionUpdate as ReturnType + ).mock.calls.filter( + (c) => + c[0]?.update?.sessionUpdate === 'user_message_chunk' && + c[0]?.update?.content?.text === + 'Loop tick — tasks from project loop.md', + ).length; + expect(labelledEchoes).toBe(2); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it('does not expand the project loop.md sentinel in an untrusted folder', async () => { // An untrusted folder's repo-controlled .qwen/loop.md must not be read // and fed to the model. With no user-owned ~/.qwen/loop.md, the tick is @@ -4613,6 +4703,92 @@ describe('Session', () => { } }); + it('reads the home loop.md from QWEN_HOME, not the real ~/.qwen', async () => { + // The home/global candidate must honor QWEN_HOME (the relocated global + // dir) instead of always reading the real OS home. Point QWEN_HOME at a + // dir holding loop.md, leave the project dir and fake $HOME empty, and + // confirm the relocated file's block reaches the model. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-qwenhome-proj-'), + ); + const fakeHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-qwenhome-home-'), + ); + const qwenHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-qwenhome-dir-'), + ); + await fs.writeFile( + path.join(qwenHome, 'loop.md'), + '- relocated home task', + ); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + const restoreHome = setFakeHome(fakeHome); + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = qwenHome; + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // Echo names the home source (sourceLabel='home loop.md'), proving the + // home candidate resolved from QWEN_HOME rather than the empty $HOME. + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'Loop tick — tasks from home loop.md', + }, + // `*/5 * * * *` is a recurring cron (not an @wakeup), so the + // echo carries source 'cron' (see job.cronExpr mapping). + _meta: { source: 'cron' }, + }, + }); + }); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + await vi.waitFor(() => { + expect(sentToModel()).toContain('- relocated home task'); + }); + } finally { + restoreHome(); + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(fakeHome, { recursive: true, force: true }); + await fs.rm(qwenHome, { recursive: true, force: true }); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b904d63f83a..3a7304d87b1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -29,6 +29,7 @@ import type { GoalTerminalEvent, ToolCallRequestInfo, ToolCallResponseInfo, + LoopTickResult, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -2464,9 +2465,20 @@ export class Session implements SessionContext { // Rebuild if the working dir changed (e.g. /cd) so loop.md resolves against // the current project; a fresh resolver also correctly re-delivers full. if (!this.loopTickResolver || this.loopTickResolverRoot !== root) { + // Resolve the home/global loop.md from the QWEN_HOME-aware global dir (the + // rest of Qwen honors QWEN_HOME for `.qwen`); reading raw os.homedir() here + // would always hit the real `~/.qwen` and ignore a relocated config home. + const homeQwenDir = Storage.getGlobalQwenDir(); + // Confinement root for the home candidate's resolved target: $QWEN_HOME + // when set (it IS the global dir), else $HOME — keeps the earlier + // confinement (an in-root dotfile symlink resolves; an escape is refused). + const homeConfineRoot = process.env['QWEN_HOME'] + ? homeQwenDir + : os.homedir(); this.loopTickResolver = new LoopTickResolver({ projectRoot: root, - homeDir: os.homedir(), + homeDir: homeConfineRoot, + homeQwenDir, // The project `.qwen/loop.md` is repo-controlled, so an untrusted folder // must not read it and feed it to the model (mirrors getProjectHooks()'s // trust gate). The home/global `~/.qwen/loop.md` is user-owned and stays @@ -2519,9 +2531,26 @@ export class Session implements SessionContext { // changed fire, a short reminder when unchanged. Non-sentinel // prompts pass through untouched. const loopMode = detectLoopSentinel(prompt); - const loopTick = loopMode - ? await this.#getLoopTickResolver().resolve(loopMode) - : null; + let loopTick: LoopTickResult | null = null; + if (loopMode) { + try { + loopTick = + await this.#getLoopTickResolver().resolve(loopMode); + } catch (resolveErr) { + // resolve() reads .qwen/loop.md (project or home/global); an + // EACCES/EIO here is a sentinel-RESOLUTION failure, not a + // model-call failure — tag it so the two are distinguishable + // in logs (the shared catch below still surfaces it). Log the + // sentinel mode + error code only, never an absolute path (the + // error message may embed one; the relative file is .qwen/loop.md). + debugLogger.warn( + `loop.md sentinel resolution failed (mode=${loopMode}, code=${ + (resolveErr as NodeJS.ErrnoException).code ?? 'unknown' + }) — check .qwen/loop.md permissions/IO`, + ); + throw resolveErr; + } + } const modelText = loopTick ? loopTick.modelText : prompt; if (loopTick) { debugLogger.debug( diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 951819275ad..1a41cd5ee27 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -755,9 +755,15 @@ export class CronScheduler { // run). They are not notified and, critically, left on disk (not // in removeMissedFromDisk) so the owning interactive session still // surfaces and runs them instead of losing the task permanently. - const runnable = pending.tasks.filter( - (t) => !this.skipDurableFire?.(durableTaskToJob(t)), - ); + const runnable = pending.tasks.filter((t) => { + if (this.skipDurableFire?.(durableTaskToJob(t))) { + debugLogger.debug( + `Skipping durable job ${t.id} (missed): consumer cannot run it`, + ); + return false; + } + return true; + }); if (runnable.length > 0) { onFire({ ...durableTaskToJob(runnable[0]!), @@ -773,10 +779,16 @@ export class CronScheduler { for (const id of pending.ids) { const job = this.jobs.get(id); if (!job) continue; // deleted while buffered - // Same skip as the tick loop: a durable job this consumer can't run - // is not fired and not stamped (left out of persistCatchUpStamps), - // so its overdue schedule survives for the owning session. - if (this.skipDurableFire?.(job)) continue; + // Same skip as the tick loop (job.durable && …): a durable job this + // consumer can't run is not fired and not stamped (left out of + // persistCatchUpStamps), so its overdue schedule survives for the + // owning session. + if (job.durable && this.skipDurableFire?.(job)) { + debugLogger.debug( + `Skipping durable job ${job.id} (catch-up): consumer cannot run it`, + ); + continue; + } onFire(job); fired.push(id); } @@ -786,9 +798,15 @@ export class CronScheduler { case 'final': { const fired: string[] = []; for (const job of pending.jobs) { - // A skipped durable job is left on disk (not in removeMissedFromDisk) - // so the owning session still gets its one final fire + delete. - if (this.skipDurableFire?.(job)) continue; + // Same skip as the tick loop (job.durable && …): a skipped durable + // job is left on disk (not in removeMissedFromDisk) so the owning + // session still gets its one final fire + delete. + if (job.durable && this.skipDurableFire?.(job)) { + debugLogger.debug( + `Skipping durable job ${job.id} (final): consumer cannot run it`, + ); + continue; + } onFire(job); fired.push(job.id); } @@ -1047,7 +1065,12 @@ export class CronScheduler { // headless run) is skipped BEFORE processJob stamps lastFiredAt — firing // it here would persist the stamp while the work is skipped downstream, // silently consuming the tick. Leave it for the owning session. - if (job.durable && this.skipDurableFire?.(job)) continue; + if (job.durable && this.skipDurableFire?.(job)) { + debugLogger.debug( + `Skipping durable job ${job.id} (tick): consumer cannot run it`, + ); + continue; + } const result = this.processJob(job, currentDate, currentMs); if (!job.durable || result === 'none') continue; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 54cf03bece1..2c080e1e263 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -338,6 +338,57 @@ describe('readLoopTaskFile', () => { }); }); + it('reads the home loop.md from a relocated homeQwenDir (QWEN_HOME)', async () => { + // The home candidate lives in the QWEN_HOME-aware global dir, not always + // /.qwen — write loop.md into a relocated global dir and confirm it + // is read as the `home` source from /loop.md. + const relocated = path.join(tempDir, 'relocated-qwen'); + await fs.mkdir(relocated, { recursive: true }); + await fs.writeFile(path.join(relocated, 'loop.md'), 'relocated user tasks'); + + const result = await readLoopTaskFile({ + projectRoot, + // Caller passes the global dir as both candidate dir and confinement root + // when QWEN_HOME is set (see Session.#getLoopTickResolver). + homeDir: relocated, + homeQwenDir: relocated, + allowProjectFile: true, + }); + + expect(result).toEqual({ + status: 'found', + path: path.join(relocated, 'loop.md'), + source: 'home', + content: 'relocated user tasks', + truncated: false, + }); + }); + + it('keeps confinement for a relocated homeQwenDir (escaping symlink refused)', async () => { + // Relocation must not loosen the earlier confinement: a symlink whose target + // escapes the home confinement root is still refused, not read. + const relocated = path.join(tempDir, 'relocated-qwen'); + await fs.mkdir(relocated, { recursive: true }); + const outside = path.join(tempDir, 'outside-secret'); + await fs.writeFile(outside, 'SECRET=should-not-be-read'); + await fs.symlink(outside, path.join(relocated, 'loop.md')); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir: relocated, + homeQwenDir: relocated, + allowProjectFile: true, + }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [ + path.join(projectRoot, '.qwen', 'loop.md'), + path.join(relocated, 'loop.md'), + ], + }); + }); + it('skips a home loop.md that is a self-referential symlink (ELOOP) instead of throwing', async () => { // fs.stat follows the home symlink; a self-referential link raises ELOOP. // That must be treated as a skippable candidate (→ missing), not crash the diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index f760dfd7de5..dd7a60ea3a5 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -31,7 +31,21 @@ export type LoopTaskFileResult = export interface ReadLoopTaskFileOptions { projectRoot: string; + /** + * Confinement root for the home candidate's resolved (symlink-followed) + * target — a target escaping this dir (e.g. `-> /etc/passwd`) is refused while + * an in-root dotfile symlink is followed. Pass `$QWEN_HOME` when set, else + * `$HOME` (see `homeQwenDir`). + */ homeDir: string; + /** + * Directory holding the home/global `loop.md` candidate (`/loop.md`). + * Pass the QWEN_HOME-aware global dir (`Storage.getGlobalQwenDir()`) so a + * relocated config home is honored instead of always reading the real OS home. + * Defaults to `/.qwen` so a direct barrel caller keeps the `~/.qwen` + * layout. + */ + homeQwenDir?: string; /** * When false, the project `.qwen/loop.md` candidate is skipped entirely — it * is repo-controlled, so an untrusted workspace must not read it and feed it @@ -156,14 +170,18 @@ async function readBoundedTaskFile(filePath: string): Promise { * final-component `lstat` cannot see. When `allowProjectFile` is false (untrusted * folder) the candidate is dropped entirely. * - * Home candidate: the user's own dotfile, so a symlink IS followed (a common, - * legitimate setup — e.g. into a synced dotfiles repo), but the resolved target - * must be a regular file AND stay within $HOME so a FIFO/device/dir can't hang - * the tick and an escaping symlink (e.g. `-> /etc/passwd`) can't be exfiltrated. + * Home candidate: `/loop.md` (the QWEN_HOME-aware global dir, not + * always the real `~/.qwen`). It is the user's own dotfile, so a symlink IS + * followed (a common, legitimate setup — e.g. into a synced dotfiles repo), but + * the resolved target must be a regular file AND stay within the home + * confinement root (`homeDir`: `$QWEN_HOME` or `$HOME`) so a FIFO/device/dir + * can't hang the tick and an escaping symlink (e.g. `-> /etc/passwd`) can't be + * exfiltrated. */ export async function readLoopTaskFile({ projectRoot, homeDir, + homeQwenDir = path.join(homeDir, '.qwen'), allowProjectFile = false, realDirCache = moduleRealDirCache, }: ReadLoopTaskFileOptions): Promise { @@ -184,7 +202,7 @@ export async function readLoopTaskFile({ }, ] : []), - { source: 'home', path: path.join(homeDir, '.qwen', 'loop.md') }, + { source: 'home', path: path.join(homeQwenDir, 'loop.md') }, ]; for (const { source, path: filePath } of candidates) { diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index cbef6cf0e55..c829ce206cb 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -33,7 +33,13 @@ export type LoopMode = 'cron' | 'dynamic'; export interface LoopTickResolverDeps { /** Pass `config.getWorkingDir()` — loop.md is resolved against the cwd. */ projectRoot: string; + /** Home-candidate confinement root: `$QWEN_HOME` when set, else `$HOME`. */ homeDir: string; + /** + * QWEN_HOME-aware global dir holding the home `loop.md` (`Storage.getGlobalQwenDir()`). + * Omitted → defaults to `/.qwen` inside readLoopTaskFile. + */ + homeQwenDir?: string; /** * Pass `() => config.isTrustedFolder()`. Re-evaluated on every `resolve()`, * never captured once: `isTrustedFolder()` is not process-stable in IDE @@ -173,6 +179,7 @@ export class LoopTickResolver { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, + homeQwenDir: this.deps.homeQwenDir, // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a // resolver built while trusted must skip the project file once trust flips. allowProjectFile: this.deps.allowProjectFile(), From d9db4950e9d83c8cc32760d515d5d8cd457dbf6f Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 19:24:36 +0800 Subject: [PATCH 17/31] test(loop): cover all-filtered missed batch and working-dir resolver rebuild Address PR review on the loop.md injection work with two load-bearing test additions: - cronScheduler: a 'missed' batch where every durable task is a loop.md sentinel filtered by skipDurableFire fires nothing and never calls removeMissedFromDisk (the runnable.length > 0 guard), so the owning interactive session still surfaces the preserved tasks. - Session: changing getWorkingDir between ticks rebuilds the loop.md resolver for the new root, resolving the new root's loop.md and never re-serving the old root's content. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 98 +++++++++++++++++++ .../core/src/services/cronScheduler.test.ts | 44 +++++++++ 2 files changed, 142 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e10dd07b169..044cf8b4fcf 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4620,6 +4620,104 @@ describe('Session', () => { } }); + it('rebuilds the loop.md resolver when the working dir changes between ticks', async () => { + // /cd mid-session: the resolver is cached per project root, so a working- + // dir change must rebuild it for the NEW root. Two ticks of the same + // sentinel — the first resolves the OLD root's loop.md; getWorkingDir then + // flips and the second must resolve the NEW root's loop.md (a fresh + // resolver → full delivery), never re-serving the OLD root's content. + // Mutation check: drop the `loopTickResolverRoot !== root` rebuild guard + // and tick2 reuses the OLD resolver — the NEW content never reaches the + // model (the unchanged OLD content is re-served as a short reminder). + const oldDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-md-old-')); + const newDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-md-new-')); + await fs.mkdir(path.join(oldDir, '.qwen'), { recursive: true }); + await fs.mkdir(path.join(newDir, '.qwen'), { recursive: true }); + await fs.writeFile( + path.join(oldDir, '.qwen', 'loop.md'), + '- task from OLD root', + ); + await fs.writeFile( + path.join(newDir, '.qwen', 'loop.md'), + '- task from NEW root', + ); + + let currentRoot = oldDir; + mockConfig.getWorkingDir = vi.fn(() => currentRoot); + + let fire: + | ((job: { prompt: string; cronExpr?: string }) => void) + | undefined; + const scheduler = { + size: 1, + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + // Capture the fire callback so the test can drive ticks one at a time, + // flipping the working dir in between. + start: vi.fn( + (cb: (job: { prompt: string; cronExpr?: string }) => void) => { + fire = cb; + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + // Bootstraps the scheduler and captures `fire`; no tick fires yet. + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + await vi.waitFor(() => expect(fire).toBeDefined()); + + const cronModelTexts = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .filter((c) => Array.isArray(c[1]?.message)) + .map((c) => + (c[1].message as Array<{ text?: string }>) + .map((p) => p.text ?? '') + .join(''), + ); + + // Tick 1 resolves against the OLD root. Waiting for its content in the + // model proves the resolve consumed oldDir before we flip (race-free: + // the model send is downstream of the resolve). + fire!({ prompt: '<>', cronExpr: '*/5 * * * *' }); + await vi.waitFor(() => { + expect( + cronModelTexts().some((t) => t.includes('task from OLD root')), + ).toBe(true); + }); + + // /cd: the resolver must rebuild for the new root on the next tick. + currentRoot = newDir; + + // Tick 2 must resolve the NEW root's loop.md (fresh resolver → full). + fire!({ prompt: '<>', cronExpr: '*/5 * * * *' }); + await vi.waitFor(() => { + expect( + cronModelTexts().some((t) => t.includes('task from NEW root')), + ).toBe(true); + }); + + // The NEW-root tick carries ONLY the new root's tasks — the old root's + // content is not re-resolved after the dir change. + const newMsg = cronModelTexts().find((t) => + t.includes('task from NEW root'), + )!; + expect(newMsg).not.toContain('task from OLD root'); + } finally { + await fs.rm(oldDir, { recursive: true, force: true }); + await fs.rm(newDir, { recursive: true, force: true }); + } + }); + it('does not expand the project loop.md sentinel in an untrusted folder', async () => { // An untrusted folder's repo-controlled .qwen/loop.md must not be read // and fed to the model. With no user-owned ~/.qwen/loop.md, the tick is diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index d4bac5cb4cc..1efc181ba57 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -1011,6 +1011,50 @@ describe('CronScheduler', () => { }); }); + it('deliverPending missed branch: an ALL-sentinel batch fires nothing and leaves every task on disk', async () => { + // All-filtered companion to the mixed-batch lock above. When a headless + // load misses ONLY <> sentinels it can't run, the + // runnable.length > 0 guard must fire NOTHING (no empty carrier notice) + // AND never call removeMissedFromDisk, so every sentinel is preserved for + // its owning interactive session. Mutation check: drop the guard and the + // empty batch fires a bogus missed notification (durableTaskToJob over an + // undefined runnable[0]). + const past = Date.now() - 10 * 60_000; + await writeCronTasks(tmpDir, [ + { + id: 'loopmd-a', + cron: '* * * * *', + prompt: '<>', + recurring: false, + createdAt: past, + lastFiredAt: null, + }, + { + id: 'loopmd-b', + cron: '* * * * *', + prompt: '<>', + recurring: false, + createdAt: past, + lastFiredAt: null, + }, + ]); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.setSkipDurableFire((job) => job.prompt === '<>'); + await scheduler.enableDurable('session-1'); + + // Nothing in the batch is runnable → no fire at all (delivery is + // synchronous within enableDurable, so this is race-free). + expect(fired).toEqual([]); + + // Both sentinels survive — removeMissedFromDisk was never reached. + expect((await readCronTasks(tmpDir)).map((t) => t.id).sort()).toEqual([ + 'loopmd-a', + 'loopmd-b', + ]); + }); + it('deliverPending catch-up branch: skips a sentinel overdue-recurring (stamp left on disk), fires a sibling', async () => { // 3h overdue, past any jitter window. The sentinel must not be fired and // must keep its on-disk lastFiredAt (left out of persistCatchUpStamps) so From f402bae9df37f408e728d9d7903fb133971ebc02 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 20:03:19 +0800 Subject: [PATCH 18/31] fix(loop): report real home loop.md path in absent tick; cover resolve() error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The absent loop.md reminder hardcoded `~/.qwen/loop.md (home)`, which is wrong once $QWEN_HOME relocates the global dir — the resolver checks `/loop.md`. Build the absent body from the resolver's real homeQwenDir (OS-home tilde-abbreviated so the unrelocated case still reads `~/.qwen/loop.md`); keep the project label relative. Also add a Session test that a `<>` sentinel whose resolve() throws (EACCES) propagates into the cron catch — surfacing as a cron error, never degrading to a default tick sent to the model — and logs the loop.md-tagged warn. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 91 +++++++++++++++++++ .../bundled/loop/loop-tick-resolver.test.ts | 43 +++++++++ .../skills/bundled/loop/loop-tick-resolver.ts | 33 +++++-- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 044cf8b4fcf..1d3ede05d2e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4887,6 +4887,97 @@ describe('Session', () => { } }); + it('propagates a sentinel resolve() error (EACCES) instead of swallowing it into a normal tick', async () => { + // #executeCronPrompt: when resolve() throws (e.g. EACCES on + // .qwen/loop.md) it logs a loop.md-specific warn and RE-THROWS into the + // cron catch. Regression guard: the failure must PROPAGATE (surface as a + // cron error, never degrade to a default/normal tick sent to the model) + // and the loop.md-tagged warn must fire so a resolution failure stays + // distinguishable from a model-call failure in logs. + debugLoggerWarnSpy.mockClear(); + const eacces = Object.assign( + new Error("EACCES: permission denied, open '.qwen/loop.md'"), + { code: 'EACCES' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The loop.md-specific warn fired, tagged with the sentinel mode and + // the EACCES code (proving the failure was logged as a resolution + // failure, not a generic model error). + await vi.waitFor(() => { + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=cron, code=EACCES) — check .qwen/loop.md permissions/IO', + ); + }); + + // The error PROPAGATED to the cron catch and surfaced to the client. + const sessionUpdateMock = mockClient.sessionUpdate as ReturnType< + typeof vi.fn + >; + const cronErrorEmitted = () => + sessionUpdateMock.mock.calls.some((call) => { + const update = ( + call[0] as { + update?: { + sessionUpdate?: string; + content?: { text?: string }; + }; + } + ).update; + const text = update?.content?.text ?? ''; + return ( + update?.sessionUpdate === 'agent_message_chunk' && + text.includes('[cron error]') && + text.includes('EACCES') + ); + }); + await vi.waitFor(() => expect(cronErrorEmitted()).toBe(true)); + + // It was NOT swallowed into a normal tick: resolve() threw before any + // model send, so neither an expanded `# /loop tick` block nor the raw + // sentinel ever reached the model (the model is only sent the user + // prompt, never a degraded default tick). + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + expect(sentToModel()).not.toContain('# /loop tick'); + expect(sentToModel()).not.toContain('<>'); + } finally { + resolveSpy.mockRestore(); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 4eba5895ba6..ae25bb4c0ec 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -15,6 +15,7 @@ import { detectLoopSentinel, } from './loop-tick-resolver.js'; import { LOOP_TASK_FILE_MAX_BYTES } from './loop-task-file.js'; +import { tildeifyPath } from '../../../utils/paths.js'; // Make only realpath observable; every other fs call stays real so the temp-dir // fixtures keep working. The default impl calls through, so behavior is unchanged @@ -293,6 +294,48 @@ describe('LoopTickResolver', () => { expect(dynTick.modelText.match(/^# /gm)).toHaveLength(1); }); + it('names the real home loop.md in the absent reminder (QWEN_HOME-aware, not a hardcoded ~/.qwen)', async () => { + // Regression: the absent body hardcoded `~/.qwen/loop.md (home)`, which is + // wrong once the global dir is relocated (QWEN_HOME) — the resolver actually + // checks `/loop.md`, so the message must name THAT path. + const relocated = path.join(tempDir, 'relocated-qwen'); + const relocatedTick = await new LoopTickResolver({ + projectRoot, + homeDir: relocated, + homeQwenDir: relocated, + allowProjectFile: () => true, + }).resolve('cron'); + + expect(relocatedTick.full).toBe(false); + expect(relocatedTick.modelText).toContain( + 'loop.md is not currently present', + ); + expect(relocatedTick.modelText).toContain( + `${tildeifyPath(path.join(relocated, 'loop.md'))} (home)`, + ); + // The old hardcoded home location is gone; the project label stays relative. + expect(relocatedTick.modelText).not.toContain('~/.qwen/loop.md'); + expect(relocatedTick.modelText).toContain('.qwen/loop.md (project)'); + + // Under the real OS home (the QWEN_HOME-unset case) the home prefix tilde- + // abbreviates, so the message reads `~/…/loop.md`, never the absolute $HOME. + const underHome = path.join( + os.homedir(), + `.qwen-loop-absent-${process.pid}`, + ); + const homeTick = await new LoopTickResolver({ + projectRoot, + homeDir: os.homedir(), + homeQwenDir: underHome, + allowProjectFile: () => true, + }).resolve('dynamic'); + + expect(homeTick.modelText).toContain( + `~/${path.basename(underHome)}/loop.md (home)`, + ); + expect(homeTick.modelText).not.toContain(os.homedir()); + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index c829ce206cb..dac0c36eca1 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; +import { tildeifyPath } from '../../../utils/paths.js'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, @@ -110,14 +112,24 @@ const SOURCE_LABELS: Record = { home: 'home loop.md', }; -// Body of the absent reminder — the H1 is supplied by tickHeading() so the -// absent tick shares the same heading style as the full block and reminder. -const SHORT_ABSENT_BODY: Record = { - cron: 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick; the recurring cron fires the next tick automatically.', +// Per-mode tail of the absent reminder. The shared prefix (built in absentBody) +// names BOTH candidate locations; only this no-op/re-arm guidance differs by mode. +const ABSENT_TAIL: Record = { + cron: 'Treat this as a no-op tick; the recurring cron fires the next tick automatically.', dynamic: - 'loop.md is not currently present at .qwen/loop.md (project) or ~/.qwen/loop.md (home). Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', + 'Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', }; +// Body of the absent reminder — the H1 is supplied by tickHeading() so the +// absent tick shares the same heading style as the full block and reminder. +// `homeLabel` is the resolver's REAL home loop.md location (so a $QWEN_HOME- +// relocated home is reported accurately instead of a hardcoded, wrong `~/.qwen`); +// its OS-home prefix is tilde-abbreviated so the common case still reads +// `~/.qwen/loop.md`. +function absentBody(mode: LoopMode, homeLabel: string): string { + return `loop.md is not currently present at .qwen/loop.md (project) or ${homeLabel} (home). ${ABSENT_TAIL[mode]}`; +} + /** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ export function detectLoopSentinel(prompt: string): LoopMode | null { const trimmed = prompt.trim(); @@ -175,6 +187,15 @@ export class LoopTickResolver { } } + /** The real home loop.md path for user-facing messages, OS-home tilde- + * abbreviated. Mirrors readLoopTaskFile's home-candidate path exactly so the + * absent reminder names the location actually checked (QWEN_HOME-aware). */ + #homeLoopLabel(): string { + const homeQwenDir = + this.deps.homeQwenDir ?? path.join(this.deps.homeDir, '.qwen'); + return tildeifyPath(path.join(homeQwenDir, 'loop.md')); + } + async resolve(mode: LoopMode): Promise { const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, @@ -194,7 +215,7 @@ export class LoopTickResolver { this.#pendingContent = null; this.#lastContent = null; return { - modelText: `${tickHeading(mode, { absent: true })}\n${SHORT_ABSENT_BODY[mode]}`, + modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.#homeLoopLabel())}`, full: false, }; } From 300060cad71e8d9bdc1da91ae01c437f35071e45 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 27 Jun 2026 22:31:28 +0800 Subject: [PATCH 19/31] fix(loop): sanitize loop.md resolve error and guard handle close The cron catch forwards a sentinel-resolution error's .message verbatim to the ACP client via emitAgentMessage. Re-throwing the raw Node fs error leaked its absolute loop.md path (OS username + dir layout) to the client/ API. Re-throw a sanitized error carrying only the relative candidate labels + errno code; keep the full detail (absolute path included) in the local debug warn. Also guard handle.close() in readBoundedTaskFile's finally: a close failure (e.g. EBADF) would otherwise replace an in-flight read/stat error (e.g. EIO) and mask the real cause. Swallow the close error (debug-log only) so the original propagates; the close is still attempted. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 66 ++++++++++++------- .../src/acp-integration/session/Session.ts | 22 +++++-- .../bundled/loop/loop-task-file.test.ts | 30 +++++++++ .../src/skills/bundled/loop/loop-task-file.ts | 9 ++- 4 files changed, 97 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 1d3ede05d2e..4a123c2637c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4887,16 +4887,23 @@ describe('Session', () => { } }); - it('propagates a sentinel resolve() error (EACCES) instead of swallowing it into a normal tick', async () => { + it('propagates a sentinel resolve() error (EACCES) without leaking the absolute path to the client', async () => { // #executeCronPrompt: when resolve() throws (e.g. EACCES on // .qwen/loop.md) it logs a loop.md-specific warn and RE-THROWS into the // cron catch. Regression guard: the failure must PROPAGATE (surface as a // cron error, never degrade to a default/normal tick sent to the model) // and the loop.md-tagged warn must fire so a resolution failure stays // distinguishable from a model-call failure in logs. + // + // Security guard: the raw fs error message embeds the ABSOLUTE loop.md + // path (OS username + dir layout). The cron catch forwards error.message + // verbatim to the client via emitAgentMessage, so the re-thrown error's + // message must be SANITIZED — relative label + errno code only, never the + // absolute path. The full detail stays in the LOCAL debug warn. debugLoggerWarnSpy.mockClear(); + const absoluteLoopMdPath = '/home/alice/project/.qwen/loop.md'; const eacces = Object.assign( - new Error("EACCES: permission denied, open '.qwen/loop.md'"), + new Error(`EACCES: permission denied, open '${absoluteLoopMdPath}'`), { code: 'EACCES' }, ); const resolveSpy = vi @@ -4930,35 +4937,50 @@ describe('Session', () => { // The loop.md-specific warn fired, tagged with the sentinel mode and // the EACCES code (proving the failure was logged as a resolution - // failure, not a generic model error). + // failure, not a generic model error). The raw error — whose message + // carries the absolute path — is passed as the second arg so the full + // detail is kept in this LOCAL log (debug logs are never sent to the + // client). await vi.waitFor(() => { expect(debugLoggerWarnSpy).toHaveBeenCalledWith( 'loop.md sentinel resolution failed (mode=cron, code=EACCES) — check .qwen/loop.md permissions/IO', + eacces, ); }); - // The error PROPAGATED to the cron catch and surfaced to the client. + // The error PROPAGATED to the cron catch and surfaced to the client, + // but SANITIZED: the emitted message names the relative candidate + // labels + errno code and NEVER the raw absolute loop.md path. const sessionUpdateMock = mockClient.sessionUpdate as ReturnType< typeof vi.fn >; - const cronErrorEmitted = () => - sessionUpdateMock.mock.calls.some((call) => { - const update = ( - call[0] as { - update?: { - sessionUpdate?: string; - content?: { text?: string }; - }; - } - ).update; - const text = update?.content?.text ?? ''; - return ( - update?.sessionUpdate === 'agent_message_chunk' && - text.includes('[cron error]') && - text.includes('EACCES') - ); - }); - await vi.waitFor(() => expect(cronErrorEmitted()).toBe(true)); + const cronErrorTexts = () => + sessionUpdateMock.mock.calls + .map( + (call) => + ( + call[0] as { + update?: { + sessionUpdate?: string; + content?: { text?: string }; + }; + } + ).update, + ) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text) => text.includes('[cron error]')); + await vi.waitFor(() => + expect(cronErrorTexts().length).toBeGreaterThan(0), + ); + for (const text of cronErrorTexts()) { + // Relative label + errno code present... + expect(text).toContain('EACCES'); + expect(text).toContain('.qwen/loop.md (project)'); + // ...and NO absolute path leaked to the client/API. + expect(text).not.toContain(absoluteLoopMdPath); + expect(text).not.toContain('/home/alice'); + } // It was NOT swallowed into a normal tick: resolve() threw before any // model send, so neither an expanded `# /loop tick` block nor the raw diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 3a7304d87b1..0c71e4d8581 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2540,15 +2540,23 @@ export class Session implements SessionContext { // resolve() reads .qwen/loop.md (project or home/global); an // EACCES/EIO here is a sentinel-RESOLUTION failure, not a // model-call failure — tag it so the two are distinguishable - // in logs (the shared catch below still surfaces it). Log the - // sentinel mode + error code only, never an absolute path (the - // error message may embed one; the relative file is .qwen/loop.md). + // in logs (the shared catch below still surfaces it). + const code = + (resolveErr as NodeJS.ErrnoException).code ?? 'unknown'; + // Full detail — including the raw fs error's ABSOLUTE loop.md + // path (OS username + dir layout) — stays in this LOCAL debug + // log only; debug logs are never sent to the ACP client. debugLogger.warn( - `loop.md sentinel resolution failed (mode=${loopMode}, code=${ - (resolveErr as NodeJS.ErrnoException).code ?? 'unknown' - }) — check .qwen/loop.md permissions/IO`, + `loop.md sentinel resolution failed (mode=${loopMode}, code=${code}) — check .qwen/loop.md permissions/IO`, + resolveErr, + ); + // Re-throw a SANITIZED error: the outer cron catch forwards + // error.message verbatim to the client via emitAgentMessage, so + // re-throwing the raw fs error would leak that absolute path. + // Surface only the relative candidate labels + errno code. + throw new Error( + `loop.md resolution failed (${code}) for .qwen/loop.md (project) or ~/.qwen/loop.md (home)`, ); - throw resolveErr; } } const modelText = loopTick ? loopTick.modelText : prompt; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 2c080e1e263..c01f79b2043 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -885,4 +885,34 @@ describe('readLoopTaskFile', () => { content: 'user tasks', }); }); + + it('lets the original read error propagate when handle.close() also throws', async () => { + // readBoundedTaskFile closes the handle in a `finally`. If read() throws + // (e.g. EIO) AND close() also throws (e.g. EBADF), an unguarded `finally` + // would replace the original I/O error with the close error, masking the + // real cause. The close is guarded, so the ORIGINAL read error must survive. + await writeProject('project tasks'); // real file so lstat/realpath/confine pass + const eio = Object.assign(new Error('EIO: i/o error, read'), { + code: 'EIO', + }); + const ebadf = Object.assign( + new Error('EBADF: bad file descriptor, close'), + { code: 'EBADF' }, + ); + const close = vi.fn().mockRejectedValue(ebadf); + const fakeHandle = { + stat: async () => ({ isFile: () => true, size: 100 }), + read: vi.fn().mockRejectedValue(eio), + close, + } as unknown as Awaited>; + // The first fs.open is the project candidate (read first); hand it the + // fake handle. lstat/realpath above this still run against the real file. + vi.mocked(fs.open).mockImplementationOnce(async () => fakeHandle); + + await expect( + readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }), + ).rejects.toBe(eio); + // The close was still attempted (we swallow its failure, not skip it). + expect(close).toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index dd7a60ea3a5..b7a3fbe032c 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -148,7 +148,14 @@ async function readBoundedTaskFile(filePath: string): Promise { } return buffer.subarray(0, total); } finally { - await handle.close(); + // Guard the close so a close failure (e.g. EBADF) can't replace an in-flight + // read/stat error (e.g. EIO) — JS would otherwise surface the close error and + // mask the original. Swallow it (debug-log only) and let the original throw. + try { + await handle.close(); + } catch (closeErr) { + debugLogger.debug('failed to close loop.md handle', { closeErr }); + } } } From 5acd11a01ef06a7b40d7dfed1d852146908a8648 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 11:53:16 +0800 Subject: [PATCH 20/31] fix(loop): align skip guards, resolve sentinel pendingRemoval limbo, QWEN_HOME-aware absent/error labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the loop.md-injection review: - cronScheduler: add the `job.durable &&` guard to the missed-branch skip so all four skip sites (missed/catch-up/final/tick) read identically. - cronScheduler: a skipped sentinel stayed on disk yet lingered in pendingRemoval, stranding it out of both the job map and disk reconciliation. Clear it from pendingRemoval (missed + final branches) so the next load re-installs it — the intended "defer to the owning session" path. Tests assert the skipped sentinel is not left in pendingRemoval. - loop-tick-resolver: absentBody no longer claims the project candidate was checked when allowProjectFile is false (untrusted folder) — it names only the home path actually read. Tests updated. - Session: the sanitized resolve-error reused a hardcoded ~/.qwen/loop.md; reuse the resolver's QWEN_HOME-aware homeLoopLabel() (now public) so a relocated global dir is reported accurately, still leak-safe. Test added. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 101 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 10 +- .../core/src/services/cronScheduler.test.ts | 17 +++ packages/core/src/services/cronScheduler.ts | 17 ++- .../bundled/loop/loop-tick-resolver.test.ts | 6 ++ .../skills/bundled/loop/loop-tick-resolver.ts | 33 ++++-- 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4a123c2637c..83d44901f5c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5000,6 +5000,107 @@ describe('Session', () => { } }); + it('names the QWEN_HOME-aware home path in the sanitized resolve error, not a hardcoded ~/.qwen', async () => { + // Regression: the sanitized resolve-error hardcoded `~/.qwen/loop.md + // (home)`, but the resolver's home candidate is QWEN_HOME-aware. With + // QWEN_HOME relocated, the error must name the REAL checked path + // (/loop.md) — reusing the resolver's homeLoopLabel() — while + // staying leak-safe (no absolute project path). + debugLoggerWarnSpy.mockClear(); + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-err-proj-'), + ); + const fakeHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-err-home-'), + ); + const qwenHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-err-qwenhome-'), + ); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + const restoreHome = setFakeHome(fakeHome); + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = qwenHome; + // qwenHome is under os.tmpdir() (not the OS home), so it is not tilde- + // abbreviated — the label is the relocated path verbatim. + const expectedHomeLabel = `${path.join(qwenHome, 'loop.md')} (home)`; + + const eacces = Object.assign( + new Error( + `EACCES: permission denied, open '${path.join(tmpDir, '.qwen', 'loop.md')}'`, + ), + { code: 'EACCES' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const sessionUpdateMock = mockClient.sessionUpdate as ReturnType< + typeof vi.fn + >; + const cronErrorTexts = () => + sessionUpdateMock.mock.calls + .map( + (call) => + ( + call[0] as { + update?: { + sessionUpdate?: string; + content?: { text?: string }; + }; + } + ).update, + ) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text) => text.includes('[cron error]')); + await vi.waitFor(() => + expect(cronErrorTexts().length).toBeGreaterThan(0), + ); + for (const text of cronErrorTexts()) { + // The QWEN_HOME-aware home path is named... + expect(text).toContain(expectedHomeLabel); + expect(text).toContain('.qwen/loop.md (project)'); + // ...and the old hardcoded label is gone. + expect(text).not.toContain('~/.qwen/loop.md'); + // Still leak-safe: no absolute project path. + expect(text).not.toContain(path.join(tmpDir, '.qwen', 'loop.md')); + } + } finally { + resolveSpy.mockRestore(); + restoreHome(); + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(fakeHome, { recursive: true, force: true }); + await fs.rm(qwenHome, { recursive: true, force: true }); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 0c71e4d8581..70b09465eb4 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2533,9 +2533,9 @@ export class Session implements SessionContext { const loopMode = detectLoopSentinel(prompt); let loopTick: LoopTickResult | null = null; if (loopMode) { + const resolver = this.#getLoopTickResolver(); try { - loopTick = - await this.#getLoopTickResolver().resolve(loopMode); + loopTick = await resolver.resolve(loopMode); } catch (resolveErr) { // resolve() reads .qwen/loop.md (project or home/global); an // EACCES/EIO here is a sentinel-RESOLUTION failure, not a @@ -2553,9 +2553,11 @@ export class Session implements SessionContext { // Re-throw a SANITIZED error: the outer cron catch forwards // error.message verbatim to the client via emitAgentMessage, so // re-throwing the raw fs error would leak that absolute path. - // Surface only the relative candidate labels + errno code. + // Surface only the relative candidate labels + errno code. The + // home label reuses the resolver's QWEN_HOME-aware tilde label + // (the real checked path), not a hardcoded `~/.qwen`. throw new Error( - `loop.md resolution failed (${code}) for .qwen/loop.md (project) or ~/.qwen/loop.md (home)`, + `loop.md resolution failed (${code}) for .qwen/loop.md (project) or ${resolver.homeLoopLabel()} (home)`, ); } } diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index 1efc181ba57..7987b1db3c9 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -1003,6 +1003,16 @@ describe('CronScheduler', () => { expect(fired[0]!.prompt).toContain('normal one-shot'); expect(fired[0]!.prompt).not.toContain('<>'); + // The skipped sentinel must NOT linger in pendingRemoval: it stays on disk + // (not removed), so a stuck guard would keep it out of both the job map and + // disk reconciliation forever. Delivery is synchronous within enableDurable, + // so this is race-free. Mutation check: drop the pendingRemoval.delete and + // this fails (the sentinel is stranded in pendingRemoval). + const pendingRemoval = ( + scheduler as unknown as { pendingRemoval: Set } + ).pendingRemoval; + expect(pendingRemoval.has('loopmd')).toBe(false); + // The sentinel survives on disk; only the fired sibling is removed. await vi.waitFor(async () => { expect((await readCronTasks(tmpDir)).map((t) => t.id)).toEqual([ @@ -1130,6 +1140,13 @@ describe('CronScheduler', () => { expect(fired.map((j) => j.prompt)).toEqual(['aged recurring']); + // Same limbo guard as the missed branch: a skipped final task stays on + // disk, so it must not be stranded in pendingRemoval. + const pendingRemoval = ( + scheduler as unknown as { pendingRemoval: Set } + ).pendingRemoval; + expect(pendingRemoval.has('loopmd-f')).toBe(false); + // The fired sibling is deleted; the skipped sentinel stays on disk. await vi.waitFor(async () => { expect((await readCronTasks(tmpDir)).map((t) => t.id)).toEqual([ diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 1a41cd5ee27..411886e1c9c 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -755,15 +755,27 @@ export class CronScheduler { // run). They are not notified and, critically, left on disk (not // in removeMissedFromDisk) so the owning interactive session still // surfaces and runs them instead of losing the task permanently. + const skipped: string[] = []; const runnable = pending.tasks.filter((t) => { - if (this.skipDurableFire?.(durableTaskToJob(t))) { + const job = durableTaskToJob(t); + // `job.durable &&` mirrors catch-up/final/tick — durableTaskToJob always + // sets durable, so it's a no-op today, but keeps the four skip sites + // identical so a future non-durable carrier can't be silently dropped. + if (job.durable && this.skipDurableFire?.(job)) { debugLogger.debug( `Skipping durable job ${t.id} (missed): consumer cannot run it`, ); + skipped.push(t.id); return false; } return true; }); + // A skipped sentinel stays on disk (not in removeMissedFromDisk) for its + // interactive owner — so drop its pendingRemoval guard too. Left set, it + // would sit out of BOTH the job map and disk reconciliation forever; + // cleared, the next loadFileTasks re-installs it (the intended + // "defer to the owning session" path). + for (const id of skipped) this.pendingRemoval.delete(id); if (runnable.length > 0) { onFire({ ...durableTaskToJob(runnable[0]!), @@ -805,6 +817,9 @@ export class CronScheduler { debugLogger.debug( `Skipping durable job ${job.id} (final): consumer cannot run it`, ); + // Same limbo as the missed branch: a skipped final task stays on + // disk, so clear its pendingRemoval guard rather than strand it. + this.pendingRemoval.delete(job.id); continue; } onFire(job); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index ae25bb4c0ec..e367144e00d 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -107,6 +107,10 @@ describe('LoopTickResolver', () => { expect(tick.full).toBe(false); expect(tick.sourceLabel).toBeUndefined(); expect(tick.modelText).toContain('loop.md is not currently present'); + // The project candidate was never read (untrusted), so the absent message + // must not claim it was checked — only the home path was. + expect(tick.modelText).not.toContain('(project)'); + expect(tick.modelText).toContain(`${tildeifyPath(homeFile())} (home)`); }); it('re-reads folder trust per tick: a trusted→untrusted flip stops reading the project file', async () => { @@ -138,6 +142,8 @@ describe('LoopTickResolver', () => { 'loop.md is not currently present', ); expect(untrustedTick.modelText).not.toContain('- repo-controlled tasks'); + // Trust is revoked, so the project file was not read — don't claim it. + expect(untrustedTick.modelText).not.toContain('(project)'); }); it('delivers the full task block on first fire', async () => { diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index dac0c36eca1..85aaa8ffd5e 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -113,7 +113,8 @@ const SOURCE_LABELS: Record = { }; // Per-mode tail of the absent reminder. The shared prefix (built in absentBody) -// names BOTH candidate locations; only this no-op/re-arm guidance differs by mode. +// names the candidate location(s) actually checked; only this no-op/re-arm +// guidance differs by mode. const ABSENT_TAIL: Record = { cron: 'Treat this as a no-op tick; the recurring cron fires the next tick automatically.', dynamic: @@ -125,9 +126,17 @@ const ABSENT_TAIL: Record = { // `homeLabel` is the resolver's REAL home loop.md location (so a $QWEN_HOME- // relocated home is reported accurately instead of a hardcoded, wrong `~/.qwen`); // its OS-home prefix is tilde-abbreviated so the common case still reads -// `~/.qwen/loop.md`. -function absentBody(mode: LoopMode, homeLabel: string): string { - return `loop.md is not currently present at .qwen/loop.md (project) or ${homeLabel} (home). ${ABSENT_TAIL[mode]}`; +// `~/.qwen/loop.md`. When `projectChecked` is false (untrusted folder), the +// project candidate is never read, so it is omitted rather than claimed checked. +function absentBody( + mode: LoopMode, + homeLabel: string, + projectChecked: boolean, +): string { + const where = projectChecked + ? `.qwen/loop.md (project) or ${homeLabel} (home)` + : `${homeLabel} (home)`; + return `loop.md is not currently present at ${where}. ${ABSENT_TAIL[mode]}`; } /** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ @@ -189,21 +198,25 @@ export class LoopTickResolver { /** The real home loop.md path for user-facing messages, OS-home tilde- * abbreviated. Mirrors readLoopTaskFile's home-candidate path exactly so the - * absent reminder names the location actually checked (QWEN_HOME-aware). */ - #homeLoopLabel(): string { + * absent reminder — and the caller's sanitized resolve-error — names the + * location actually checked (QWEN_HOME-aware). Public so the Session error + * message reuses the same label instead of hardcoding a wrong `~/.qwen`. */ + homeLoopLabel(): string { const homeQwenDir = this.deps.homeQwenDir ?? path.join(this.deps.homeDir, '.qwen'); return tildeifyPath(path.join(homeQwenDir, 'loop.md')); } async resolve(mode: LoopMode): Promise { + // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a + // resolver built while trusted must skip the project file once trust flips. + // Captured so the absent reminder reflects what was ACTUALLY checked. + const allowProjectFile = this.deps.allowProjectFile(); const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, homeQwenDir: this.deps.homeQwenDir, - // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a - // resolver built while trusted must skip the project file once trust flips. - allowProjectFile: this.deps.allowProjectFile(), + allowProjectFile, realDirCache: this.#realDirCache, }); @@ -215,7 +228,7 @@ export class LoopTickResolver { this.#pendingContent = null; this.#lastContent = null; return { - modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.#homeLoopLabel())}`, + modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.homeLoopLabel(), allowProjectFile)}`, full: false, }; } From eb0f082fc5b02ce17a1ddd75c67dfdf68fdbdaec Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 12:24:52 +0800 Subject: [PATCH 21/31] fix(loop): never leak an absolute $QWEN_HOME path in the home loop.md label homeLoopLabel() built the model-facing home loop.md label via tildeifyPath(join(homeQwenDir, 'loop.md')). homeQwenDir honors $QWEN_HOME, which may point OUTSIDE $HOME (a supported relocation, common in containers/CI). tildeifyPath only abbreviates $HOME-prefixed paths, so for a $QWEN_HOME outside $HOME it was a no-op and the raw absolute path flowed through absentBody() and the sanitized resolve-error into model/API text, leaking the host's filesystem layout. Label the three cases without ever emitting an absolute path: - under $HOME -> tilde-abbreviated ~/.qwen/loop.md (unchanged); - relocated via $QWEN_HOME -> the literal $QWEN_HOME/loop.md (env-var name, not the resolved dir), by swapping the resolved prefix; - any other out-of-$HOME dir -> a generic placeholder. The real absolute path stays in LOCAL debug logs only. Two existing tests codified the leak (asserting the absolute path appeared); fixed and added a privacy test (label + absent-tick modelText use the placeholder, never the absolute path). Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 17 ++-- .../bundled/loop/loop-tick-resolver.test.ts | 94 ++++++++++++++----- .../skills/bundled/loop/loop-tick-resolver.ts | 30 ++++-- 3 files changed, 106 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 83d44901f5c..dc4823fa918 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5003,9 +5003,9 @@ describe('Session', () => { it('names the QWEN_HOME-aware home path in the sanitized resolve error, not a hardcoded ~/.qwen', async () => { // Regression: the sanitized resolve-error hardcoded `~/.qwen/loop.md // (home)`, but the resolver's home candidate is QWEN_HOME-aware. With - // QWEN_HOME relocated, the error must name the REAL checked path - // (/loop.md) — reusing the resolver's homeLoopLabel() — while - // staying leak-safe (no absolute project path). + // QWEN_HOME relocated OUTSIDE $HOME, the error reuses homeLoopLabel(), + // which names it via the literal `$QWEN_HOME/loop.md` — leak-safe (never + // the resolved absolute global dir, nor the absolute project path). debugLoggerWarnSpy.mockClear(); const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'loop-md-err-proj-'), @@ -5020,9 +5020,10 @@ describe('Session', () => { const restoreHome = setFakeHome(fakeHome); const prevQwenHome = process.env['QWEN_HOME']; process.env['QWEN_HOME'] = qwenHome; - // qwenHome is under os.tmpdir() (not the OS home), so it is not tilde- - // abbreviated — the label is the relocated path verbatim. - const expectedHomeLabel = `${path.join(qwenHome, 'loop.md')} (home)`; + // qwenHome is under os.tmpdir() (not the OS home), so tildeifyPath is a + // no-op there. The label is MODEL/client-facing, so it must read as the + // literal `$QWEN_HOME/loop.md`, never the resolved absolute path. + const expectedHomeLabel = `$QWEN_HOME/loop.md (home)`; const eacces = Object.assign( new Error( @@ -5087,8 +5088,10 @@ describe('Session', () => { expect(text).toContain('.qwen/loop.md (project)'); // ...and the old hardcoded label is gone. expect(text).not.toContain('~/.qwen/loop.md'); - // Still leak-safe: no absolute project path. + // Still leak-safe: neither the absolute project path nor the + // resolved $QWEN_HOME global dir reaches the client/API. expect(text).not.toContain(path.join(tmpDir, '.qwen', 'loop.md')); + expect(text).not.toContain(path.join(qwenHome, 'loop.md')); } } finally { resolveSpy.mockRestore(); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index e367144e00d..6849f02fda9 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -15,7 +15,6 @@ import { detectLoopSentinel, } from './loop-tick-resolver.js'; import { LOOP_TASK_FILE_MAX_BYTES } from './loop-task-file.js'; -import { tildeifyPath } from '../../../utils/paths.js'; // Make only realpath observable; every other fs call stays real so the temp-dir // fixtures keep working. The default impl calls through, so behavior is unchanged @@ -108,9 +107,12 @@ describe('LoopTickResolver', () => { expect(tick.sourceLabel).toBeUndefined(); expect(tick.modelText).toContain('loop.md is not currently present'); // The project candidate was never read (untrusted), so the absent message - // must not claim it was checked — only the home path was. + // must not claim it was checked — only the home candidate is named, via a + // leak-safe label (this fixture's homeDir is a temp dir outside the real + // $HOME, so the absolute path must never reach the model text). expect(tick.modelText).not.toContain('(project)'); - expect(tick.modelText).toContain(`${tildeifyPath(homeFile())} (home)`); + expect(tick.modelText).toContain('(home)'); + expect(tick.modelText).not.toContain(homeFile()); }); it('re-reads folder trust per tick: a trusted→untrusted flip stops reading the project file', async () => { @@ -302,26 +304,35 @@ describe('LoopTickResolver', () => { it('names the real home loop.md in the absent reminder (QWEN_HOME-aware, not a hardcoded ~/.qwen)', async () => { // Regression: the absent body hardcoded `~/.qwen/loop.md (home)`, which is - // wrong once the global dir is relocated (QWEN_HOME) — the resolver actually - // checks `/loop.md`, so the message must name THAT path. + // wrong once the global dir is relocated (QWEN_HOME). The resolver checks + // `/loop.md`, but the label is MODEL-FACING, so a $QWEN_HOME + // outside $HOME (tildeifyPath no-op there) must read as the literal + // `$QWEN_HOME/loop.md`, never the raw absolute path it would otherwise leak. const relocated = path.join(tempDir, 'relocated-qwen'); - const relocatedTick = await new LoopTickResolver({ - projectRoot, - homeDir: relocated, - homeQwenDir: relocated, - allowProjectFile: () => true, - }).resolve('cron'); - - expect(relocatedTick.full).toBe(false); - expect(relocatedTick.modelText).toContain( - 'loop.md is not currently present', - ); - expect(relocatedTick.modelText).toContain( - `${tildeifyPath(path.join(relocated, 'loop.md'))} (home)`, - ); - // The old hardcoded home location is gone; the project label stays relative. - expect(relocatedTick.modelText).not.toContain('~/.qwen/loop.md'); - expect(relocatedTick.modelText).toContain('.qwen/loop.md (project)'); + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = relocated; + try { + const relocatedTick = await new LoopTickResolver({ + projectRoot, + homeDir: relocated, + homeQwenDir: relocated, + allowProjectFile: () => true, + }).resolve('cron'); + + expect(relocatedTick.full).toBe(false); + expect(relocatedTick.modelText).toContain( + 'loop.md is not currently present', + ); + expect(relocatedTick.modelText).toContain('$QWEN_HOME/loop.md (home)'); + // The old hardcoded home location is gone; the project label stays relative. + expect(relocatedTick.modelText).not.toContain('~/.qwen/loop.md'); + expect(relocatedTick.modelText).toContain('.qwen/loop.md (project)'); + // Privacy: the raw absolute global dir never reaches the model text. + expect(relocatedTick.modelText).not.toContain(relocated); + } finally { + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + } // Under the real OS home (the QWEN_HOME-unset case) the home prefix tilde- // abbreviates, so the message reads `~/…/loop.md`, never the absolute $HOME. @@ -342,6 +353,45 @@ describe('LoopTickResolver', () => { expect(homeTick.modelText).not.toContain(os.homedir()); }); + it('homeLoopLabel never leaks an absolute $QWEN_HOME path outside $HOME (privacy)', async () => { + // The label is sent to the model/API. $QWEN_HOME may point OUTSIDE $HOME + // (supported relocation; common in containers/CI), where tildeifyPath is a + // no-op — so the resolved absolute dir must be swapped for the literal + // `$QWEN_HOME`. Mutation guard: revert homeLoopLabel to + // `tildeifyPath(join(homeQwenDir,'loop.md'))` and `outside` (the absolute + // path) reappears in BOTH assertions below, failing this test. + const outside = path.join(tempDir, 'srv-qwen-home'); + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = outside; + try { + const relocated = new LoopTickResolver({ + projectRoot, + homeDir: outside, + homeQwenDir: outside, + allowProjectFile: () => true, + }); + expect(relocated.homeLoopLabel()).toBe('$QWEN_HOME/loop.md'); + const tick = await relocated.resolve('cron'); + expect(tick.modelText).toContain('$QWEN_HOME/loop.md (home)'); + expect(tick.modelText).not.toContain(outside); + + // Defensive case: an out-of-$HOME global dir with $QWEN_HOME UNSET still + // never surfaces the absolute path — a generic placeholder is used. + delete process.env['QWEN_HOME']; + const generic = new LoopTickResolver({ + projectRoot, + homeDir: outside, + homeQwenDir: outside, + allowProjectFile: () => true, + }); + expect(generic.homeLoopLabel()).toBe('the configured global loop.md'); + expect(generic.homeLoopLabel()).not.toContain(outside); + } finally { + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + } + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 85aaa8ffd5e..c87b8a9c2c1 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -196,15 +196,33 @@ export class LoopTickResolver { } } - /** The real home loop.md path for user-facing messages, OS-home tilde- - * abbreviated. Mirrors readLoopTaskFile's home-candidate path exactly so the - * absent reminder — and the caller's sanitized resolve-error — names the - * location actually checked (QWEN_HOME-aware). Public so the Session error - * message reuses the same label instead of hardcoding a wrong `~/.qwen`. */ + /** MODEL-FACING label for the home loop.md location. Mirrors + * readLoopTaskFile's home candidate (`/loop.md`) so the absent + * reminder — and the caller's sanitized resolve-error — names the location + * actually checked (QWEN_HOME-aware), but must NEVER surface a raw absolute + * path: it flows into model/API text, leaking the host's filesystem layout. + * - under $HOME → tilde-abbreviated `~/.qwen/loop.md`; + * - relocated via $QWEN_HOME → the literal `$QWEN_HOME/loop.md`, not the + * resolved dir (`tildeifyPath` only abbreviates $HOME, so it's a no-op for + * a $QWEN_HOME outside $HOME and would otherwise pass the path through); + * - any other out-of-$HOME dir → a generic placeholder, never the path. + * The real absolute path stays in LOCAL debug logs only. */ homeLoopLabel(): string { const homeQwenDir = this.deps.homeQwenDir ?? path.join(this.deps.homeDir, '.qwen'); - return tildeifyPath(path.join(homeQwenDir, 'loop.md')); + const homeLoopPath = path.join(homeQwenDir, 'loop.md'); + + const tildeified = tildeifyPath(homeLoopPath); + if (tildeified !== homeLoopPath) { + return tildeified; + } + // Outside $HOME: tildeifyPath was a no-op. When $QWEN_HOME relocated the + // global dir (homeQwenDir is its resolved value), report the literal env-var + // name by swapping the resolved prefix — never the absolute path. + if (process.env['QWEN_HOME']) { + return `$QWEN_HOME${homeLoopPath.slice(homeQwenDir.length)}`; + } + return 'the configured global loop.md'; } async resolve(mode: LoopMode): Promise { From 44f45c0e8c59d765301f240b9290b81308302786 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 14:15:12 +0800 Subject: [PATCH 22/31] fix(loop): guard UTF-8 truncation boundary and scope resolve-error to checked candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a loop.md task file is byte-capped on a UTF-8 boundary, the trailing back-off only dropped a still-INCOMPLETE lead (`lead + width > end`). It missed the symmetric case where a complete character is followed by stray orphan bytes (`lead + width < end`), and it ran only once. Because `lead` is found by walking back over continuation bytes, an ASCII byte between an orphan lead and the cap (e.g. `c3 41 80 80 80 61`) stopped the walk early: the orphan `c3` and the stray `80` continuations survived and decoded to trailing U+FFFD that the byte-length re-clamp cannot remove. Replace the single check with a re-checking loop that keeps a trailing unit only when its declared width reaches `end` exactly, and re-checks the new tail since several malformed units can stack. Adds a mutation-checked test using the orphan-lead repro. Also make the sentinel resolve-error path trust-aware. The sanitized error always named `.qwen/loop.md (project)`, even for an untrusted folder where the project candidate is never read — the same false claim the absent reminder already avoids. Introduce LoopTickResolver.absentLocations(projectChecked) as the single source of truth for the checked-candidate "where" string (project named only when actually read, QWEN_HOME-aware never-absolute home label) and use it from both absentBody and the Session throw, so the two messages cannot drift. Adds a Session test asserting an untrusted folder omits `(project)` while still naming the home label. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 92 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 13 ++- .../bundled/loop/loop-task-file.test.ts | 35 +++++++ .../src/skills/bundled/loop/loop-task-file.ts | 35 ++++--- .../skills/bundled/loop/loop-tick-resolver.ts | 34 ++++--- 5 files changed, 177 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index dc4823fa918..9e83b4c7e0c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5104,6 +5104,98 @@ describe('Session', () => { } }); + it('omits the project candidate from the sanitized resolve error in an untrusted folder', async () => { + // An untrusted folder never reads `.qwen/loop.md` (the resolver gets + // allowProjectFile=false), so the sanitized error must NOT claim the + // project candidate was checked — it would be a lie. It still names the + // QWEN_HOME-aware home candidate (the only one actually probed) and the + // errno code, and stays leak-safe. Mutation guard: hardcoding + // `.qwen/loop.md (project)` back into the throw re-introduces the false + // claim and fails this test. + debugLoggerWarnSpy.mockClear(); + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-untrusted-err-'), + ); + const fakeHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-untrusted-home-'), + ); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + const restoreHome = setFakeHome(fakeHome); + + const absoluteLoopMdPath = path.join(tmpDir, '.qwen', 'loop.md'); + const eacces = Object.assign( + new Error(`EACCES: permission denied, open '${absoluteLoopMdPath}'`), + { code: 'EACCES' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const sessionUpdateMock = mockClient.sessionUpdate as ReturnType< + typeof vi.fn + >; + const cronErrorTexts = () => + sessionUpdateMock.mock.calls + .map( + (call) => + ( + call[0] as { + update?: { + sessionUpdate?: string; + content?: { text?: string }; + }; + } + ).update, + ) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text) => text.includes('[cron error]')); + await vi.waitFor(() => + expect(cronErrorTexts().length).toBeGreaterThan(0), + ); + for (const text of cronErrorTexts()) { + // The home candidate and errno code are named... + expect(text).toContain('EACCES'); + expect(text).toContain('(home)'); + // ...but the never-read project candidate is omitted entirely. + expect(text).not.toContain('(project)'); + // ...and the absolute path is still never leaked to the client/API. + expect(text).not.toContain(absoluteLoopMdPath); + } + } finally { + resolveSpy.mockRestore(); + restoreHome(); + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 70b09465eb4..05b2463a891 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2553,11 +2553,16 @@ export class Session implements SessionContext { // Re-throw a SANITIZED error: the outer cron catch forwards // error.message verbatim to the client via emitAgentMessage, so // re-throwing the raw fs error would leak that absolute path. - // Surface only the relative candidate labels + errno code. The - // home label reuses the resolver's QWEN_HOME-aware tilde label - // (the real checked path), not a hardcoded `~/.qwen`. + // Surface only the candidate labels + errno code via the shared + // absentLocations() — so the QWEN_HOME-aware home label (never a + // hardcoded `~/.qwen`) is reused AND the project candidate is + // named only for a trusted folder, where it was actually read + // (an untrusted folder skips it, so claiming `(project)` would be + // a lie). Trust is re-evaluated here to match the resolve() tick. throw new Error( - `loop.md resolution failed (${code}) for .qwen/loop.md (project) or ${resolver.homeLoopLabel()} (home)`, + `loop.md resolution failed (${code}) for ${resolver.absentLocations( + this.config.isTrustedFolder(), + )}`, ); } } diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index c01f79b2043..5fc52c13d74 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -854,6 +854,41 @@ describe('readLoopTaskFile', () => { } }); + it('drops an ORPHAN lead followed by an ASCII byte and stray continuations (no U+FFFD)', async () => { + // The continuation back-off walks `lead` to the LAST non-continuation byte, + // so a `> end` width check alone stops at the ASCII `0x41` (a complete 1-byte + // char) and keeps the orphan `0xc3` before it plus the three stray `0x80` + // continuations after it — all of which decode to trailing U+FFFD. Re-checking + // the boundary against the EXACT char width (and re-running after each trim) + // is what strips the whole malformed tail. The trailing `0x61` defeats the + // initial continuation back-off, so the stray `0x80` bytes are not at the very + // end and only the boundary loop removes them. + const N = LOOP_TASK_FILE_MAX_BYTES; + const head = Buffer.alloc(N - 5, 0x61); // 'a' * (N-5) + const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80, 0x80, 0x61]); // orphan lead, 'A', 3 conts, 'a' + const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result.status).toBe('found'); + if (result.status === 'found') { + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + // The whole malformed tail is gone: no replacement char, and the body ends + // on the last complete ('a') char — a clean UTF-8 boundary. + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 5)); + } + }); + it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => { // The over-long-path code is in the skip whitelist but otherwise untested; a // typo'd entry would start throwing on a real ENAMETOOLONG instead of falling diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index b7a3fbe032c..c65ab6437dd 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -311,17 +311,25 @@ export async function readLoopTaskFile({ while (end > 0 && (buffer[end] & 0xc0) === 0x80) { end--; } - // ...then drop a still-INCOMPLETE trailing char: walk to the last lead byte - // and, if its declared width runs past `end` (a 4-byte `f0` with too few - // continuations, from a mid-sequence cut or malformed input), cut before it. - // The continuation walk alone leaves such an orphan lead, which decodes to a - // trailing U+FFFD the byte-length re-clamp below can keep — so this boundary - // fix is load-bearing and the re-clamp is a pure safety net. - let lead = end - 1; - while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) { - lead--; - } - if (lead >= 0) { + // ...then drop any malformed trailing unit. `lead` is the last + // non-continuation byte, and the back-off skipped exactly the continuation + // bytes after it, so the trailing character is well-formed iff its declared + // width reaches `end` exactly. A mismatch is either an INCOMPLETE lead (too + // few continuations, `lead + width > end`) or an ORPHAN lead whose stray + // continuations belong to nothing (`lead + width < end`) — e.g. a width + // check that only tests `> end` keeps `c3 41 80 80 80` (orphan `c3` plus + // stray continuations after the `41`). Drop the unit and re-check, since + // several malformed units can stack. Each surviving orphan decodes to a + // trailing U+FFFD the byte-length re-clamp below cannot remove, so this loop + // is load-bearing and the re-clamp is a pure safety net. + while (end > 0) { + let lead = end - 1; + while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) { + lead--; + } + if (lead < 0) { + break; + } const b = buffer[lead]; const width = (b & 0x80) === 0x00 @@ -333,9 +341,10 @@ export async function readLoopTaskFile({ : (b & 0xf8) === 0xf0 ? 4 : 1; // invalid lead (0xC0/0xC1/0xF8–0xFF): treat as a 1-byte unit - if (lead + width > end) { - end = lead; + if (lead + width === end) { + break; // a complete, well-formed trailing character } + end = lead; } content = buffer.subarray(0, end).toString('utf8'); while (Buffer.byteLength(content, 'utf8') > LOOP_TASK_FILE_MAX_BYTES) { diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index c87b8a9c2c1..de6c8787ae2 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -123,20 +123,11 @@ const ABSENT_TAIL: Record = { // Body of the absent reminder — the H1 is supplied by tickHeading() so the // absent tick shares the same heading style as the full block and reminder. -// `homeLabel` is the resolver's REAL home loop.md location (so a $QWEN_HOME- -// relocated home is reported accurately instead of a hardcoded, wrong `~/.qwen`); -// its OS-home prefix is tilde-abbreviated so the common case still reads -// `~/.qwen/loop.md`. When `projectChecked` is false (untrusted folder), the -// project candidate is never read, so it is omitted rather than claimed checked. -function absentBody( - mode: LoopMode, - homeLabel: string, - projectChecked: boolean, -): string { - const where = projectChecked - ? `.qwen/loop.md (project) or ${homeLabel} (home)` - : `${homeLabel} (home)`; - return `loop.md is not currently present at ${where}. ${ABSENT_TAIL[mode]}`; +// `locations` is LoopTickResolver.absentLocations(): the candidate path(s) +// ACTUALLY checked this tick (the project candidate is omitted on an untrusted +// folder), with a QWEN_HOME-aware home label that is never a raw absolute path. +function absentBody(mode: LoopMode, locations: string): string { + return `loop.md is not currently present at ${locations}. ${ABSENT_TAIL[mode]}`; } /** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ @@ -225,6 +216,19 @@ export class LoopTickResolver { return 'the configured global loop.md'; } + /** The checked-candidate "where" string shared by the absent reminder and the + * caller's sanitized resolve-error. Names the project candidate ONLY when it + * was actually read (`projectChecked` — a trusted folder), so neither path can + * claim `.qwen/loop.md (project)` for an untrusted folder where the project + * file is skipped. The home label is the QWEN_HOME-aware, never-absolute + * homeLoopLabel(). Single source of truth so the two messages can't drift. */ + absentLocations(projectChecked: boolean): string { + const homeLabel = this.homeLoopLabel(); + return projectChecked + ? `.qwen/loop.md (project) or ${homeLabel} (home)` + : `${homeLabel} (home)`; + } + async resolve(mode: LoopMode): Promise { // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a // resolver built while trusted must skip the project file once trust flips. @@ -246,7 +250,7 @@ export class LoopTickResolver { this.#pendingContent = null; this.#lastContent = null; return { - modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.homeLoopLabel(), allowProjectFile)}`, + modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.absentLocations(allowProjectFile))}`, full: false, }; } From 21aebdd16f3d39943fd29ec34dc4ca8db0d76825 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 15:18:33 +0800 Subject: [PATCH 23/31] test(loop): lock that one-shot sentinel jobs are not deleted by the headless skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skipHeadlessLoopSentinel deletion guard is `job.recurring && !job.durable` — both conditions required. All existing tests use recurring: true, so a simplification to `!job.durable` would wrongly evict a one-shot sentinel job with nothing to catch it. Add a locking test: a non-recurring sentinel-prompt job still returns the skip result but is never passed to scheduler.delete. Co-Authored-By: Qwen-Coder --- packages/cli/src/nonInteractiveCli.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 5bed522be60..f6e2c8e4072 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -132,6 +132,19 @@ describe('skipHeadlessLoopSentinel', () => { expect(deleteSpy).not.toHaveBeenCalled(); }); + + it('does not delete a non-recurring sentinel job (one-shot stays in the scheduler)', () => { + // The deletion branch requires BOTH `recurring && !durable`. A one-shot + // sentinel job is already removed by the scheduler before it fires, so this + // guard must NOT delete it — a `!durable`-only guard would wrongly evict it. + const scheduler = new CronScheduler(); + const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, false); + const deleteSpy = vi.spyOn(scheduler, 'delete'); + + expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); + + expect(deleteSpy).not.toHaveBeenCalled(); + }); }); describe('runNonInteractive', () => { From 2f43aeb4a5741a6f65f5e62945c1c72063948d8c Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 17:29:02 +0800 Subject: [PATCH 24/31] fix(loop): guard empty homedir confinement root; interpolate dynamic sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two low-severity hardening fixes for the loop.md tick path. - Session.ts: when QWEN_HOME is unset and os.homedir() returns '' (minimal containers with no HOME), homeConfineRoot collapsed to ''. It is passed as the home-candidate symlink confinement root, and isWithin('', target) is trivially true — so a home ~/.qwen/loop.md symlink could resolve anywhere, bypassing the boundary. Fall back to path.dirname(homeQwenDir) (the parent of the always-non-empty Storage.getGlobalQwenDir()) so the root is never empty. Adds a focused test that drives a dynamic tick with os.homedir()==='' and asserts the resolver receives a non-empty homeDir (mutation-checked). - loop-tick-resolver.ts: PACING_SUFFIX.dynamic and ABSENT_TAIL.dynamic hardcoded the literal `<>` in their user-facing instructions. Interpolate the exported LOOP_SENTINEL_DYNAMIC constant instead so a future rename can't silently drift the instruction. Adds one assertion referencing the constant on the absent-tick path (the full-block path was already covered). Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 78 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 12 ++- .../bundled/loop/loop-tick-resolver.test.ts | 4 + .../skills/bundled/loop/loop-tick-resolver.ts | 6 +- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 9e83b4c7e0c..c289d5133a8 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -35,6 +35,9 @@ import { CommandKind } from '../../ui/commands/types.js'; import { MessageType } from '../../ui/types.js'; const debugLoggerWarnSpy = vi.hoisted(() => vi.fn()); +// Records every LoopTickResolver construction's deps so a test can assert what +// Session computed (e.g. the home confinement root) without a private-field peek. +const loopTickResolverDepsSpy = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = @@ -49,6 +52,16 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }), generatePromptSuggestion: vi.fn(), logPromptSuggestion: vi.fn(), + // Transparent recording wrapper: records the constructor deps, then behaves + // exactly like the real resolver (subclass → instanceof + methods preserved). + LoopTickResolver: class extends actual.LoopTickResolver { + constructor( + ...args: ConstructorParameters + ) { + loopTickResolverDepsSpy(args[0]); + super(...args); + } + }, }; }); @@ -4801,6 +4814,71 @@ describe('Session', () => { } }); + it('keeps the home confinement root non-empty when os.homedir() is empty (no QWEN_HOME)', async () => { + // Minimal containers with no HOME make os.homedir() === ''. With QWEN_HOME + // unset the home confinement root must NOT collapse to '': isWithin('', + // anyPath) is trivially true, so an empty root lets a home + // `~/.qwen/loop.md` symlink resolve anywhere and bypass the confinement. + // The guard falls back to the parent of the global qwen dir + // (Storage.getGlobalQwenDir(), itself empty-home-safe), which is the + // homeQwenDir Session passes to the resolver. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'loop-md-nohome-'), + ); + mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir); + // HOME='' makes libuv's os.homedir() return '' on every platform — it + // null-checks HOME, never its emptiness. + const restoreHome = setFakeHome(''); + const prevQwenHome = process.env['QWEN_HOME']; + delete process.env['QWEN_HOME']; + loopTickResolverDepsSpy.mockClear(); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(loopTickResolverDepsSpy).toHaveBeenCalled(); + }); + + const deps = loopTickResolverDepsSpy.mock.calls.at(-1)![0] as { + homeDir: string; + homeQwenDir?: string; + }; + // Without the `|| path.dirname(homeQwenDir)` guard this would be '' + // (os.homedir()); the guard makes it the non-empty parent of the + // empty-home-safe global qwen dir. + expect(deps.homeDir).not.toBe(''); + expect(deps.homeDir).toBe(path.dirname(deps.homeQwenDir!)); + } finally { + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + restoreHome(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it('reads the home loop.md from QWEN_HOME, not the real ~/.qwen', async () => { // The home/global candidate must honor QWEN_HOME (the relocated global // dir) instead of always reading the real OS home. Point QWEN_HOME at a diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 05b2463a891..2ea8af6c2ed 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -5,6 +5,7 @@ */ import * as os from 'node:os'; +import * as path from 'node:path'; import type { Content, FunctionCall, @@ -2472,9 +2473,14 @@ export class Session implements SessionContext { // Confinement root for the home candidate's resolved target: $QWEN_HOME // when set (it IS the global dir), else $HOME — keeps the earlier // confinement (an in-root dotfile symlink resolves; an escape is refused). - const homeConfineRoot = process.env['QWEN_HOME'] - ? homeQwenDir - : os.homedir(); + // The `|| path.dirname(homeQwenDir)` guards an empty os.homedir() (minimal + // containers with no HOME): an empty root makes isWithin('', target) always + // true, trivially bypassing the symlink confinement. homeQwenDir + // (Storage.getGlobalQwenDir()) is always non-empty, so its parent is a + // sound non-empty fallback root. + const homeConfineRoot = + (process.env['QWEN_HOME'] ? homeQwenDir : os.homedir()) || + path.dirname(homeQwenDir); this.loopTickResolver = new LoopTickResolver({ projectRoot: root, homeDir: homeConfineRoot, diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 6849f02fda9..787cc7c515e 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -298,6 +298,10 @@ describe('LoopTickResolver', () => { expect(dynTick.modelText).toContain( '# /loop tick — loop.md absent (dynamic pacing)\n', ); + // The absent dynamic tail names the re-arm sentinel by interpolating the + // constant — asserting against LOOP_SENTINEL_DYNAMIC catches a future rename + // drift between the constant and the user-facing instruction. + expect(dynTick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); // Exactly one H1 — the heading isn't duplicated by the body. expect(dynTick.modelText.match(/^# /gm)).toHaveLength(1); }); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index de6c8787ae2..5553980873c 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -72,8 +72,7 @@ const INTRO = // reminder — the no-op/re-arm instruction applies on every tick. const PACING_SUFFIX: Record = { cron: 'The recurring cron fires the next tick automatically — do not call LoopWakeup from this tick.', - dynamic: - 'You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', + dynamic: `You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel \`${LOOP_SENTINEL_DYNAMIC}\` — otherwise the loop ends after this tick.`, }; // Preamble for the UNCHANGED-tick reminder, which points back to the full block @@ -117,8 +116,7 @@ const SOURCE_LABELS: Record = { // guidance differs by mode. const ABSENT_TAIL: Record = { cron: 'Treat this as a no-op tick; the recurring cron fires the next tick automatically.', - dynamic: - 'Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel `<>` — otherwise the loop ends after this tick.', + dynamic: `Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel \`${LOOP_SENTINEL_DYNAMIC}\` — otherwise the loop ends after this tick.`, }; // Body of the absent reminder — the H1 is supplied by tickHeading() so the From 75c6c37322d94111441041ec21d25ced0d9ec8b8 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 18:03:18 +0800 Subject: [PATCH 25/31] fix(loop): reject hard-linked loop.md and keep dynamic loops alive on transient read errors Hardens the .qwen/loop.md fire-time resolution surfaced by wenshao's review. nlink hard-link exfiltration (Critical): the project and home loop.md candidate checks verified isSymbolicLink()/isFile() + workspace/home confinement but never inspected nlink. A hard link such as `ln .env .qwen/loop.md` (or `ln ~/.ssh/id_ed25519 ~/.qwen/loop.md`) is an indistinguishable regular file sharing the secret's inode; it passed every check and isWithin (same inode, same fs), so the secret was read and injected into the model prompt every tick. Both candidates now reject `nlink > 1`, mirroring canonicalizeKeytermsFile in voice-keyterms.ts. dynamic loop silent death (correctness): a transient, non-whitelisted fs error (EACCES/EIO, or a Windows editor/AV briefly locking the file) made resolve() throw, the cron catch re-throw, and the turn unwind before the model ran. For a dynamic (self-paced) loop this is fatal and permanent: the firing wakeup was already consumed and the loop only survives because the model re-arms LoopWakeup at end-of-turn, so no turn means no re-arm. The catch now degrades a dynamic tick to a model-facing no-op that mirrors the absent path (heading + ABSENT_TAIL.dynamic re-arm + errno note) so the loop survives the hiccup; cron keeps throwing (it re-fires on its own next interval). The real errno stays in the LOCAL debug log, never leaking the absolute path to the client. Also: capture folder-trust once per tick and thread it into both resolve() and the sanitized error's absentLocations(), so a mid-tick trust flip can't make the error name a different candidate set than was probed; and log the previously silent whitespace-only skip so a present-but-empty loop.md is distinguishable from an absent one in debug logs. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 203 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 56 +++-- .../bundled/loop/loop-task-file.test.ts | 102 +++++++++ .../src/skills/bundled/loop/loop-task-file.ts | 24 +++ .../bundled/loop/loop-tick-resolver.test.ts | 69 ++++++ .../skills/bundled/loop/loop-tick-resolver.ts | 66 ++++-- 6 files changed, 492 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index c289d5133a8..75e9f4b2af0 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5274,6 +5274,209 @@ describe('Session', () => { } }); + it('threads one captured folder-trust into both the resolve probe and the sanitized error', async () => { + // FIX 3: isTrustedFolder() can flip mid-tick (IDE workspace-trust + // update). Capturing it ONCE and threading it to BOTH resolve() and the + // error's absentLocations() keeps the sanitized error naming the SAME + // candidate set that was probed. Assert the trust handed to resolve() is + // identical to the one handed to absentLocations(). Mutation guard: + // reverting to two separate isTrustedFolder() reads drops the resolve() + // trust arg (undefined), so the two no longer match. + debugLoggerWarnSpy.mockClear(); + const eacces = Object.assign( + new Error("EACCES: permission denied, open '/home/x/.qwen/loop.md'"), + { code: 'EACCES' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + const absentSpy = vi.spyOn( + core.LoopTickResolver.prototype, + 'absentLocations', + ); + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(true); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => expect(resolveSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(absentSpy).toHaveBeenCalled()); + // resolve() was probed with the captured trust as its 2nd arg, and the + // error's absentLocations() got the SAME value — one capture, both + // paths agree. + const probedTrust = resolveSpy.mock.calls[0][1]; + const erroredTrust = absentSpy.mock.calls[0][0]; + expect(probedTrust).toBe(true); + expect(erroredTrust).toBe(true); + expect(probedTrust).toBe(erroredTrust); + } finally { + resolveSpy.mockRestore(); + absentSpy.mockRestore(); + } + }); + + it('keeps a dynamic loop alive on a transient resolve error (no throw, re-arm tick)', async () => { + // FIX 4: a `dynamic` loop is re-armed only by the model at end-of-turn, + // and the firing wakeup was already consumed. A transient, non-whitelisted + // resolve error (EIO) must NOT throw (no turn → no re-arm → silent death) + // — it degrades to a no-op tick that mirrors the absent path AND carries + // the dynamic re-arm instruction, so the model re-arms and the loop + // survives. Mutation guard: drop the `dynamic` branch (always throw) and a + // `[loop error]` surfaces while no tick reaches the model. + debugLoggerWarnSpy.mockClear(); + const eio = Object.assign(new Error('EIO: i/o error, read'), { + code: 'EIO', + }); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eio); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : [])) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + const errorEchoes = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('error]')); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The degraded no-op tick reached the model (the turn ran → no throw). + await vi.waitFor(() => { + expect(sentToModel()).toContain( + '# /loop tick — loop.md absent (dynamic pacing)', + ); + }); + // It carries the dynamic re-arm instruction (the literal sentinel) and + // the errno note, so the loop continues. + expect(sentToModel()).toContain('<>'); + expect(sentToModel()).toContain('could not be read this tick (EIO)'); + // It did NOT surface as a loop/cron error (the loop did not die). + expect(errorEchoes()).toHaveLength(0); + // The real errno is still recorded in the LOCAL debug warn. + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=dynamic, code=EIO) — check .qwen/loop.md permissions/IO', + eio, + ); + } finally { + resolveSpy.mockRestore(); + } + }); + + it('still throws on a transient resolve error for a cron loop (no degraded tick)', async () => { + // The cron counterpart to the dynamic-survival path: cron re-fires on its + // own next interval, so a transient resolve error STILL propagates + // (sanitized) rather than degrading to a model tick. Mutation guard: + // widening the dynamic no-throw branch to cron would send a `# /loop tick` + // block instead of surfacing the error. + debugLoggerWarnSpy.mockClear(); + const eio = Object.assign(new Error('EIO: i/o error, read'), { + code: 'EIO', + }); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eio); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const cronErrorTexts = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('[cron error]')); + await vi.waitFor(() => + expect(cronErrorTexts().length).toBeGreaterThan(0), + ); + // Sanitized error carries the errno; no degraded loop tick was sent. + for (const text of cronErrorTexts()) { + expect(text).toContain('EIO'); + } + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + expect(sentToModel()).not.toContain('# /loop tick'); + } finally { + resolveSpy.mockRestore(); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2ea8af6c2ed..baf71280155 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2540,13 +2540,19 @@ export class Session implements SessionContext { let loopTick: LoopTickResult | null = null; if (loopMode) { const resolver = this.#getLoopTickResolver(); + // Capture folder-trust ONCE for this tick and thread it through + // both the resolve probe and the error path. isTrustedFolder() + // can flip mid-tick (an IDE workspace-trust update), so two + // separate reads could let the sanitized error name a different + // candidate set than resolve() actually probed. + const trustedAtResolve = this.config.isTrustedFolder(); try { - loopTick = await resolver.resolve(loopMode); + loopTick = await resolver.resolve(loopMode, trustedAtResolve); } catch (resolveErr) { // resolve() reads .qwen/loop.md (project or home/global); an // EACCES/EIO here is a sentinel-RESOLUTION failure, not a // model-call failure — tag it so the two are distinguishable - // in logs (the shared catch below still surfaces it). + // in logs. const code = (resolveErr as NodeJS.ErrnoException).code ?? 'unknown'; // Full detail — including the raw fs error's ABSOLUTE loop.md @@ -2556,20 +2562,38 @@ export class Session implements SessionContext { `loop.md sentinel resolution failed (mode=${loopMode}, code=${code}) — check .qwen/loop.md permissions/IO`, resolveErr, ); - // Re-throw a SANITIZED error: the outer cron catch forwards - // error.message verbatim to the client via emitAgentMessage, so - // re-throwing the raw fs error would leak that absolute path. - // Surface only the candidate labels + errno code via the shared - // absentLocations() — so the QWEN_HOME-aware home label (never a - // hardcoded `~/.qwen`) is reused AND the project candidate is - // named only for a trusted folder, where it was actually read - // (an untrusted folder skips it, so claiming `(project)` would be - // a lie). Trust is re-evaluated here to match the resolve() tick. - throw new Error( - `loop.md resolution failed (${code}) for ${resolver.absentLocations( - this.config.isTrustedFolder(), - )}`, - ); + if (loopMode === 'dynamic') { + // A `dynamic` (self-paced) loop is kept alive ONLY by the + // model re-arming LoopWakeup at the end of each turn; the + // firing wakeup was already consumed, so throwing here (no + // turn → no re-arm) would silently kill the loop forever on a + // transient hiccup (EACCES/EIO, or a Windows editor/AV briefly + // locking the file). Degrade to a no-op tick mirroring the + // absent path so the model still re-arms and the loop survives. + // (`cron` re-fires on its own next interval, so it still + // throws below.) The captured trust names the SAME candidate + // set the probe used; the errno (no absolute path) is noted. + loopTick = resolver.buildTransientErrorTick( + loopMode, + trustedAtResolve, + code, + ); + } else { + // Re-throw a SANITIZED error: the outer cron catch forwards + // error.message verbatim to the client via emitAgentMessage, + // so re-throwing the raw fs error would leak that absolute + // path. Surface only the candidate labels + errno code via the + // shared absentLocations() — reusing the QWEN_HOME-aware home + // label (never a hardcoded `~/.qwen`) and naming the project + // candidate only when it was actually read (the captured trust + // matches the resolve() probe, so an untrusted folder can't + // falsely claim `(project)`). + throw new Error( + `loop.md resolution failed (${code}) for ${resolver.absentLocations( + trustedAtResolve, + )}`, + ); + } } } const modelText = loopTick ? loopTick.modelText : prompt; diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 5fc52c13d74..40f0ec3b059 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -21,6 +21,20 @@ vi.mock('node:fs/promises', async (importActual) => { return { ...actual, open: vi.fn(actual.open) }; }); +// Capture the module's debug calls so a test can assert WHY a candidate was +// skipped (the whitespace-only branch is the load-bearing case). Other tests +// don't read it; production debug() no-ops without an active session anyway. +const debugSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + isEnabled: () => true, + debug: debugSpy, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + describe('readLoopTaskFile', () => { let tempDir: string; let projectRoot: string; @@ -222,6 +236,72 @@ describe('readLoopTaskFile', () => { }); }); + it('does not read a HARD-LINKED project loop.md (exfiltration guard)', async () => { + // The case the symlink guard misses: `ln .qwen/loop.md` makes + // loop.md an ordinary regular file (lstat sees no symlink, isFile() true) + // that SHARES the secret's inode (nlink === 2). It resolves to itself inside + // the workspace, so confinement passes too — only the `nlink > 1` guard + // refuses it. Mutation check: drop that guard and the secret is returned as + // the project source instead of falling through to home. + await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true }); + const secret = path.join(tempDir, 'secret-env'); + await fs.writeFile(secret, 'SECRET=should-not-be-read'); + const projectLoop = path.join(projectRoot, '.qwen', 'loop.md'); + await fs.link(secret, projectLoop); // hard link → nlink 2, same inode + // Precondition: the link really is a hard link to the secret, not a symlink. + const linkStat = await fs.lstat(projectLoop); + expect(linkStat.isSymbolicLink()).toBe(false); + expect(linkStat.nlink).toBeGreaterThan(1); + await writeHome('user tasks'); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + // The hard-linked project file is skipped → home is read; the secret content + // is never returned from any candidate. + expect(result).toEqual({ + status: 'found', + path: path.join(homeDir, '.qwen', 'loop.md'), + source: 'home', + content: 'user tasks', + truncated: false, + }); + if (result.status === 'found') { + expect(result.content).not.toContain('SECRET'); + } + }); + + it('does not read a HARD-LINKED home loop.md (exfiltration guard)', async () => { + // Same hard-link vector on the home candidate: `ln ~/.qwen/loop.md`. + // fs.stat follows to a regular file with nlink 2, so isFile()/confinement + // pass — only the `nlink > 1` guard refuses it. No project file here, so the + // result is `missing`; the secret content is never returned. + await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true }); + const secret = path.join(tempDir, 'home-secret'); + await fs.writeFile(secret, 'SECRET=should-not-be-read'); + const homeLoop = path.join(homeDir, '.qwen', 'loop.md'); + await fs.link(secret, homeLoop); + const linkStat = await fs.lstat(homeLoop); + expect(linkStat.nlink).toBeGreaterThan(1); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toEqual({ + status: 'missing', + checkedPaths: [ + path.join(projectRoot, '.qwen', 'loop.md'), + path.join(homeDir, '.qwen', 'loop.md'), + ], + }); + }); + it('does not falsely refuse a project loop.md when the workspace root is a filesystem root', async () => { // When the CLI runs from a filesystem root, realRoot is `/` (or `C:\`), so the // old `realRoot + path.sep` prefix became `//` (`C:\\`) — which no descendant @@ -617,6 +697,28 @@ describe('readLoopTaskFile', () => { }); }); + it('logs a debug line when it skips a whitespace-only loop.md', async () => { + // The whitespace-only skip was the ONLY skip branch with no debug log, so a + // present-but-empty file was indistinguishable from an absent one in logs. + // Assert the labelled skip line fires for the project candidate before the + // fall-through to home. + await writeProject(' \n\t \n'); + await writeHome('user tasks'); + debugSpy.mockClear(); + + const result = await readLoopTaskFile({ + projectRoot, + homeDir, + allowProjectFile: true, + }); + + expect(result).toMatchObject({ source: 'home', content: 'user tasks' }); + expect(debugSpy).toHaveBeenCalledWith('skipping whitespace-only loop.md', { + source: 'project', + filePath: path.join(projectRoot, '.qwen', 'loop.md'), + }); + }); + it('returns missing when every candidate is empty', async () => { await writeProject(''); await writeHome('\n \n'); diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.ts b/packages/core/src/skills/bundled/loop/loop-task-file.ts index c65ab6437dd..749d221e227 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.ts @@ -231,6 +231,17 @@ export async function readLoopTaskFile({ }); continue; } + // A hard-linked loop.md is an ordinary regular file (lstat sees no + // symlink) but shares a sensitive target's inode (e.g. `ln .env + // .qwen/loop.md`), so confinement passes on the same fs and the secret + // would be read every tick. `nlink > 1` is the only tell — refuse it, + // mirroring canonicalizeKeytermsFile. + if (projectStat.nlink > 1) { + debugLogger.debug('skipping hard-linked project loop.md', { + filePath, + }); + continue; + } // A final-component lstat can't see an ANCESTOR symlink (e.g. a // checked-in `.qwen -> /outside`); realpath resolves it, so confine the // canonical path to the workspace root before reading. @@ -257,6 +268,13 @@ export async function readLoopTaskFile({ debugLogger.debug('skipping non-regular home loop.md', { filePath }); continue; } + // Same hard-link guard as the project candidate: a `nlink > 1` regular + // file shares another inode's content (e.g. `ln ~/.ssh/id_ed25519 + // ~/.qwen/loop.md`) and would otherwise be read and fed to the model. + if (homeStat.nlink > 1) { + debugLogger.debug('skipping hard-linked home loop.md', { filePath }); + continue; + } // A home symlink IS followed, but its target must stay WITHIN $HOME: // otherwise `~/.qwen/loop.md -> /etc/passwd` (or `-> /dev/...`) would be // read and fed to the model every tick. In-home dotfile symlinks (e.g. @@ -298,7 +316,13 @@ export async function readLoopTaskFile({ } // A whitespace-only file is not a task list; fall through to the next path. + // Log it (like every other skip branch) so a present-but-empty loop.md is + // distinguishable from an absent one in debug logs. if (buffer.toString('utf8').trim().length === 0) { + debugLogger.debug('skipping whitespace-only loop.md', { + source, + filePath, + }); continue; } diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 787cc7c515e..b8781080466 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -306,6 +306,75 @@ describe('LoopTickResolver', () => { expect(dynTick.modelText.match(/^# /gm)).toHaveLength(1); }); + it('resolve() honors an explicit allowProjectFile override over the getter', async () => { + // FIX 3: the caller captures folder-trust ONCE per tick and threads it in, + // so the per-tick getter is bypassed. Build a resolver whose getter would + // ALLOW the project file, but pass `false`: the repo-controlled project + // loop.md must be skipped on this tick, exactly as the getter-false path. + await writeProject('- repo-controlled tasks'); + const threaded = new LoopTickResolver({ + projectRoot, + homeDir, + allowProjectFile: () => true, // getter would allow... + }); + + const tick = await threaded.resolve('cron', false); // ...override forbids + + expect(tick.full).toBe(false); + expect(tick.modelText).not.toContain('- repo-controlled tasks'); + expect(tick.modelText).not.toContain('(project)'); + expect(tick.modelText).toContain('(home)'); + }); + + it('buildTransientErrorTick mirrors the absent tick with a re-arm and errno note', () => { + // FIX 4: a transient, non-whitelisted read error must NOT kill a dynamic + // loop. The degraded tick mirrors the absent path — same heading + the + // dynamic re-arm sentinel — plus a note that the file was unreadable this + // tick, so the model still re-arms LoopWakeup and the loop survives. + const tick = resolver.buildTransientErrorTick('dynamic', true, 'EIO'); + + expect(tick.full).toBe(false); + expect(tick.modelText).toContain( + '# /loop tick — loop.md absent (dynamic pacing)\n', + ); + expect(tick.modelText).toContain('could not be read this tick (EIO)'); + // The dynamic re-arm instruction (the literal sentinel) keeps the loop alive. + expect(tick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); + // projectChecked=true names BOTH candidates (the set that was probed). + expect(tick.modelText).toContain('(project)'); + expect(tick.modelText).toContain('(home)'); + }); + + it('cron buildTransientErrorTick uses the cron tail and omits an unprobed project', () => { + // cron mode degrades only via its own next interval, but the tick text still + // uses the cron no-op tail (no LoopWakeup re-arm). With projectChecked=false + // (untrusted) the never-probed project candidate must NOT be named. + const tick = resolver.buildTransientErrorTick('cron', false, 'EACCES'); + + expect(tick.modelText).toContain('could not be read this tick (EACCES)'); + expect(tick.modelText).toContain('the recurring cron fires the next tick'); + expect(tick.modelText).not.toContain(LOOP_SENTINEL_DYNAMIC); + expect(tick.modelText).not.toContain('(project)'); + expect(tick.modelText).toContain('(home)'); + }); + + it('a transient-error tick clears the change-detection cache so the next read re-delivers full', async () => { + // The degraded tick must behave like absent for caching: after it, a read of + // byte-identical content re-expands the FULL block rather than a dangling + // short reminder pointing at a block no longer guaranteed to be in context. + // Mutation guard: if buildTransientErrorTick doesn't clear the caches, the + // second resolve sees "unchanged" and returns a short reminder (full:false). + await writeProject('- tasks'); + const full = await resolver.resolve('dynamic'); + expect(full.full).toBe(true); + resolver.markDelivered(); + + resolver.buildTransientErrorTick('dynamic', true, 'EIO'); + + const next = await resolver.resolve('dynamic'); + expect(next.full).toBe(true); + }); + it('names the real home loop.md in the absent reminder (QWEN_HOME-aware, not a hardcoded ~/.qwen)', async () => { // Regression: the absent body hardcoded `~/.qwen/loop.md (home)`, which is // wrong once the global dir is relocated (QWEN_HOME). The resolver checks diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 5553980873c..00e626971f5 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -227,11 +227,57 @@ export class LoopTickResolver { : `${homeLabel} (home)`; } - async resolve(mode: LoopMode): Promise { + /** A model-facing no-op tick (loop.md absent, or unreadable this tick). Clears + * the change-detection caches so a later successful tick re-delivers the FULL + * block instead of a dangling short reminder pointing at a block no longer + * guaranteed to be in context — absence (and a failed read) is itself a state + * change. */ + #noOpTick(modelText: string): LoopTickResult { + this.#pendingContent = null; + this.#lastContent = null; + return { modelText, full: false }; + } + + /** + * No-op tick for a transient, non-whitelisted read error (EACCES/EIO, or a + * Windows editor/AV briefly locking loop.md). Mirrors the absent tick — same + * heading + the mode's re-arm tail (ABSENT_TAIL) — so a `dynamic` loop still + * re-arms LoopWakeup and survives the hiccup instead of dying silently: its + * firing wakeup was already consumed by the scheduler, and only the + * end-of-turn re-arm keeps it alive, so a thrown turn ends the loop forever. + * `cron` callers don't use this (they re-fire on their own next interval). + * `projectChecked` is the trust captured for THIS tick (so the named candidate + * set matches what was probed); `code` is the errno only — never an absolute + * path — for a brief model-facing note. + */ + buildTransientErrorTick( + mode: LoopMode, + projectChecked: boolean, + code: string, + ): LoopTickResult { + return this.#noOpTick( + `${tickHeading(mode, { absent: true })}\nloop.md at ${this.absentLocations( + projectChecked, + )} could not be read this tick (${code}). ${ABSENT_TAIL[mode]}`, + ); + } + + /** + * @param allowProjectFileOverride Trust captured once by the caller for this + * tick (see LoopTickResolverDeps.allowProjectFile). Threaded in — rather than + * re-reading the getter here — so the caller's error path can name the SAME + * candidate set that was probed even if `isTrustedFolder()` flips mid-tick. + * Omitted by direct callers, who fall back to the per-tick getter. + */ + async resolve( + mode: LoopMode, + allowProjectFileOverride?: boolean, + ): Promise { // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a // resolver built while trusted must skip the project file once trust flips. // Captured so the absent reminder reflects what was ACTUALLY checked. - const allowProjectFile = this.deps.allowProjectFile(); + const allowProjectFile = + allowProjectFileOverride ?? this.deps.allowProjectFile(); const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, @@ -241,16 +287,12 @@ export class LoopTickResolver { }); if (result.status === 'missing') { - // Absence is itself a state change: clear both caches so a later recreate - // — even with byte-identical content — re-expands the full block rather - // than sending a dangling short reminder that points at a block no longer - // guaranteed to be in context. - this.#pendingContent = null; - this.#lastContent = null; - return { - modelText: `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.absentLocations(allowProjectFile))}`, - full: false, - }; + // Absence is itself a state change: #noOpTick clears both caches so a + // later recreate — even with byte-identical content — re-expands the full + // block rather than sending a dangling short reminder. + return this.#noOpTick( + `${tickHeading(mode, { absent: true })}\n${absentBody(mode, this.absentLocations(allowProjectFile))}`, + ); } const content = result.truncated From 9271c432cec273046c225645268ec20373caaece Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 18:56:09 +0800 Subject: [PATCH 26/31] test(loop): hoist expects out of conditional branches to satisfy vitest/no-conditional-expect The loop.md test additions placed expect() calls inside if-narrowing branches (if (result.status === 'found') { ... }) and an if (typeof text === 'string') guard inside a for-loop, which the vitest/no-conditional-expect rule (an error in the recommended preset) flags. Hoist each assertion to the top level: assert result.status first, then a plain throw-guard to narrow the discriminated union, then the assertions unconditionally. In Session.test.ts, move the string type-filter into a .filter() so the expect runs in a plain for-of (loops are not conditionals). Pure lint-compliance refactor: assertions and coverage are unchanged. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 12 +- .../bundled/loop/loop-task-file.test.ts | 107 ++++++++++-------- 2 files changed, 64 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 75e9f4b2af0..4f270e05f40 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4510,13 +4510,13 @@ describe('Session', () => { }); }); // The absolute loop.md path must not appear in any client echo. - for (const call of ( + const echoedTexts = ( mockClient.sessionUpdate as ReturnType - ).mock.calls) { - const text = call[0]?.update?.content?.text; - if (typeof text === 'string') { - expect(text).not.toContain(loopMdPath); - } + ).mock.calls + .map((call) => call[0]?.update?.content?.text) + .filter((text): text is string => typeof text === 'string'); + for (const text of echoedTexts) { + expect(text).not.toContain(loopMdPath); } // The model receives the expanded full task block, not the sentinel. diff --git a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts index 40f0ec3b059..86b8420c96f 100644 --- a/packages/core/src/skills/bundled/loop/loop-task-file.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-task-file.test.ts @@ -269,9 +269,10 @@ describe('readLoopTaskFile', () => { content: 'user tasks', truncated: false, }); - if (result.status === 'found') { - expect(result.content).not.toContain('SECRET'); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.content).not.toContain('SECRET'); }); it('does not read a HARD-LINKED home loop.md (exfiltration guard)', async () => { @@ -760,12 +761,13 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(Buffer.byteLength(result.content, 'utf8')).toBe( - LOOP_TASK_FILE_MAX_BYTES, - ); - expect(result.truncated).toBe(true); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.truncated).toBe(true); }); it('bounds the read for a very large file (never reads past the cap)', async () => { @@ -786,12 +788,13 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(Buffer.byteLength(result.content, 'utf8')).toBe( - LOOP_TASK_FILE_MAX_BYTES, - ); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); // A single bounded fs.open handle, not fs.readFile of the whole. expect(openSpy).toHaveBeenCalledTimes(1); // Load-bearing: every read, and the total bytes requested, stay within cap. @@ -841,12 +844,13 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(Buffer.byteLength(result.content, 'utf8')).toBe( - LOOP_TASK_FILE_MAX_BYTES, - ); - expect(result.truncated).toBe(false); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(Buffer.byteLength(result.content, 'utf8')).toBe( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.truncated).toBe(false); }); it('truncates on a UTF-8 boundary without exceeding the cap or inserting a replacement char', async () => { @@ -860,13 +864,14 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( - LOOP_TASK_FILE_MAX_BYTES, - ); - expect(result.content).not.toContain('�'); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + expect(result.content).not.toContain('�'); }); it('drops an INCOMPLETE trailing multi-byte sequence at the cap (no orphan lead / U+FFFD)', async () => { @@ -889,16 +894,17 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( - LOOP_TASK_FILE_MAX_BYTES, - ); - // The incomplete sequence is gone entirely — no replacement char, and the - // body ends on the last complete ('a') char. - expect(result.content).not.toContain('�'); - expect(result.content.endsWith('a')).toBe(true); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + // The incomplete sequence is gone entirely — no replacement char, and the + // body ends on the last complete ('a') char. + expect(result.content).not.toContain('�'); + expect(result.content.endsWith('a')).toBe(true); }); it('drops an INCOMPLETE trailing 2-byte lead at the cap (covers the 2-byte width branch)', async () => { @@ -922,11 +928,12 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(result.content).not.toContain('�'); - expect(result.content).toBe('a'.repeat(N - 3)); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 3)); }); it('drops an INCOMPLETE trailing 3-byte lead at the cap (covers the 3-byte width branch)', async () => { @@ -949,11 +956,12 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(result.content).not.toContain('�'); - expect(result.content).toBe('a'.repeat(N - 4)); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 4)); }); it('drops an ORPHAN lead followed by an ASCII byte and stray continuations (no U+FFFD)', async () => { @@ -979,16 +987,17 @@ describe('readLoopTaskFile', () => { }); expect(result.status).toBe('found'); - if (result.status === 'found') { - expect(result.truncated).toBe(true); - expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( - LOOP_TASK_FILE_MAX_BYTES, - ); - // The whole malformed tail is gone: no replacement char, and the body ends - // on the last complete ('a') char — a clean UTF-8 boundary. - expect(result.content).not.toContain('�'); - expect(result.content).toBe('a'.repeat(N - 5)); + if (result.status !== 'found') { + throw new Error('expected loop.md to be found'); } + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual( + LOOP_TASK_FILE_MAX_BYTES, + ); + // The whole malformed tail is gone: no replacement char, and the body ends + // on the last complete ('a') char — a clean UTF-8 boundary. + expect(result.content).not.toContain('�'); + expect(result.content).toBe('a'.repeat(N - 5)); }); it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => { From 54082f0b8d6c74a6e2091b90937f7eb3a2c49d99 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 28 Jun 2026 20:32:08 +0800 Subject: [PATCH 27/31] fix(loop): normalize home label trailing slash; distinguish transient-error echo homeLoopLabel() built the $QWEN_HOME label by slicing homeLoopPath at homeQwenDir.length, but Storage.getGlobalQwenDir() does not strip a trailing slash, so QWEN_HOME=/x/.qwen/ over-counted the separator and produced the garbled $QWEN_HOMEloop.md. Slice past path.dirname(homeLoopPath) instead, which is always trailing-slash-free; the no-trailing-slash case is unchanged. The loop-tick echo said "loop.md not present" whenever sourceLabel was absent, which also fired for buildTransientErrorTick's dynamic-mode survival tick (a file that exists but failed to read this tick). Add a transientError flag on LoopTickResult, set only by buildTransientErrorTick, and branch the echo to "loop.md temporarily unavailable" for that case; genuinely-absent stays "not present". No errno/path leaks into the echo. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 15 +++++ .../src/acp-integration/session/Session.ts | 8 ++- .../bundled/loop/loop-tick-resolver.test.ts | 57 +++++++++++++++++++ .../skills/bundled/loop/loop-tick-resolver.ts | 21 +++++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4f270e05f40..fe0d5a5a256 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5400,6 +5400,21 @@ describe('Session', () => { // the errno note, so the loop continues. expect(sentToModel()).toContain('<>'); expect(sentToModel()).toContain('could not be read this tick (EIO)'); + // The CLIENT echo distinguishes a transient read failure (file present, + // unreadable this tick) from a genuinely-absent file: it must say + // "temporarily unavailable", never the misleading "not present". + // Mutation guard: drop the transientError flag/echo branch and the echo + // regresses to "not present", failing both assertions below. + const loopEchoes = ( + mockClient.sessionUpdate as ReturnType + ).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'user_message_chunk') + .map((u) => u?.content?.text ?? ''); + expect(loopEchoes).toContain( + 'Loop tick — loop.md temporarily unavailable', + ); + expect(loopEchoes).not.toContain('Loop tick — loop.md not present'); // It did NOT surface as a loop/cron error (the loop did not die). expect(errorEchoes()).toHaveLength(0); // The real errno is still recorded in the LOCAL debug warn. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index baf71280155..60aa7600d8f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2615,7 +2615,13 @@ export class Session implements SessionContext { const echoText = loopTick ? loopTick.sourceLabel ? `Loop tick — tasks from ${loopTick.sourceLabel}` - : 'Loop tick — loop.md not present' + : // A transient-error tick (buildTransientErrorTick) resolved a + // file but couldn't read it this tick; it deliberately omits + // sourceLabel, so don't conflate it with a genuinely-absent + // loop.md. No errno/path here — those stay in the model text. + loopTick.transientError + ? 'Loop tick — loop.md temporarily unavailable' + : 'Loop tick — loop.md not present' : prompt; // Echo the cron prompt as a user message so the client sees it diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index b8781080466..a54dbeb06c3 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -105,6 +105,9 @@ describe('LoopTickResolver', () => { expect(tick.full).toBe(false); expect(tick.sourceLabel).toBeUndefined(); + // A genuinely-absent tick is NOT flagged transient, so the echo says "not + // present" rather than "temporarily unavailable". + expect(tick.transientError).toBe(false); expect(tick.modelText).toContain('loop.md is not currently present'); // The project candidate was never read (untrusted), so the absent message // must not claim it was checked — only the home candidate is named, via a @@ -334,6 +337,9 @@ describe('LoopTickResolver', () => { const tick = resolver.buildTransientErrorTick('dynamic', true, 'EIO'); expect(tick.full).toBe(false); + // Flagged transient (file present, unreadable this tick) so the caller's echo + // can say "temporarily unavailable" rather than the genuinely-absent label. + expect(tick.transientError).toBe(true); expect(tick.modelText).toContain( '# /loop tick — loop.md absent (dynamic pacing)\n', ); @@ -465,6 +471,57 @@ describe('LoopTickResolver', () => { } }); + it('homeLoopLabel keeps the separator when $QWEN_HOME has a trailing slash', async () => { + // Storage.getGlobalQwenDir() does NOT strip a trailing slash, so a + // `QWEN_HOME=/srv/qwen/` reaches homeQwenDir as `/srv/qwen/`. Slicing the + // joined loop.md path by the raw homeQwenDir length over-counts the trailing + // separator and garbles the label into `$QWEN_HOMEloop.md`. Mutation guard: + // revert the slice base to `homeQwenDir.length` and the first assertion below + // fails with the separator-less `$QWEN_HOMEloop.md`. + const outsideTrailing = path.join(tempDir, 'srv-qwen-home') + path.sep; + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = outsideTrailing; + try { + const trailing = new LoopTickResolver({ + projectRoot, + homeDir: outsideTrailing, + homeQwenDir: outsideTrailing, + allowProjectFile: () => true, + }); + expect(trailing.homeLoopLabel()).toBe('$QWEN_HOME/loop.md'); + // Never the raw absolute dir, and never the garbled separator-less form. + expect(trailing.homeLoopLabel()).not.toContain(outsideTrailing); + expect(trailing.homeLoopLabel()).not.toContain('$QWEN_HOMEloop.md'); + + // out-of-$HOME branch still behaves with QWEN_HOME UNSET: generic placeholder. + delete process.env['QWEN_HOME']; + const generic = new LoopTickResolver({ + projectRoot, + homeDir: outsideTrailing, + homeQwenDir: outsideTrailing, + allowProjectFile: () => true, + }); + expect(generic.homeLoopLabel()).toBe('the configured global loop.md'); + } finally { + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + } + + // under-$HOME branch still behaves with a trailing slash: tilde-abbreviated. + const underHomeTrailing = + path.join(os.homedir(), `.qwen-loop-trailing-${process.pid}`) + path.sep; + const underHome = new LoopTickResolver({ + projectRoot, + homeDir: os.homedir(), + homeQwenDir: underHomeTrailing, + allowProjectFile: () => true, + }); + expect(underHome.homeLoopLabel()).toBe( + `~/.qwen-loop-trailing-${process.pid}/loop.md`, + ); + expect(underHome.homeLoopLabel()).not.toContain(os.homedir()); + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 00e626971f5..2f476368ef1 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -61,6 +61,12 @@ export interface LoopTickResult { * when present — safe for logs/UI that must not leak the absolute path, and * doubles as the "a loop.md was found" flag for callers. */ sourceLabel?: string; + /** True ONLY for buildTransientErrorTick: a loop.md exists but could not be + * read THIS tick (a transient EACCES/EIO or editor/AV lock), as distinct from + * the genuinely-absent no-op (where this stays false). Lets the caller's echo + * say "temporarily unavailable" instead of "not present". Carries no errno or + * path — those stay in the modelText note and LOCAL debug logs only. */ + transientError?: boolean; } const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; @@ -207,9 +213,13 @@ export class LoopTickResolver { } // Outside $HOME: tildeifyPath was a no-op. When $QWEN_HOME relocated the // global dir (homeQwenDir is its resolved value), report the literal env-var - // name by swapping the resolved prefix — never the absolute path. + // name by swapping the resolved prefix — never the absolute path. Slice past + // path.dirname(homeLoopPath), not homeQwenDir.length: Storage.getGlobalQwenDir() + // doesn't strip a trailing slash, so `$QWEN_HOME=/x/.qwen/` reaches here as + // `/x/.qwen/` and its length over-counts the separator, garbling the tail into + // `$QWEN_HOMEloop.md`. dirname of the joined path is always trailing-slash-free. if (process.env['QWEN_HOME']) { - return `$QWEN_HOME${homeLoopPath.slice(homeQwenDir.length)}`; + return `$QWEN_HOME${homeLoopPath.slice(path.dirname(homeLoopPath).length)}`; } return 'the configured global loop.md'; } @@ -232,10 +242,10 @@ export class LoopTickResolver { * block instead of a dangling short reminder pointing at a block no longer * guaranteed to be in context — absence (and a failed read) is itself a state * change. */ - #noOpTick(modelText: string): LoopTickResult { + #noOpTick(modelText: string, transientError = false): LoopTickResult { this.#pendingContent = null; this.#lastContent = null; - return { modelText, full: false }; + return { modelText, full: false, transientError }; } /** @@ -259,6 +269,9 @@ export class LoopTickResolver { `${tickHeading(mode, { absent: true })}\nloop.md at ${this.absentLocations( projectChecked, )} could not be read this tick (${code}). ${ABSENT_TAIL[mode]}`, + // Flag the tick as a transient read failure (file exists, unreadable this + // tick) so the caller's echo distinguishes it from a genuinely-absent file. + true, ); } From 211671567d067b9526ff0793f0bbee0aded843ce Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 29 Jun 2026 01:21:17 +0800 Subject: [PATCH 28/31] fix(loop): only degrade transient fs errors in dynamic mode; log transient flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dynamic (self-paced) loop mode, the loop.md sentinel-resolution catch degraded EVERY resolve() error to a no-op re-arm tick. A non-fs error (a TypeError / assertion → code 'unknown') therefore entered an infinite silent no-op cycle: the loop never died and the real bug never surfaced. Gate the degradation on a known-transient fs code set (TRANSIENT_FS_CODES: EACCES/EIO/EBUSY/EPERM/ENOENT); any other (unexpected) error falls through to the existing sanitized throw so it surfaces instead of looping forever. Also distinguish a transient-error tick from a genuinely-absent loop.md in the debug log: both produce full:false + sourceLabel:undefined, so the line printed delivery=absent for both. Add transient=${transientError ?? false} so an oncall engineer can tell 'file missing' from 'file unreadable'. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 207 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 30 ++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index fe0d5a5a256..10ff30d7d4d 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5492,6 +5492,213 @@ describe('Session', () => { } }); + it('keeps a dynamic loop alive on a transient EACCES resolve error', async () => { + // EACCES is in TRANSIENT_FS_CODES, so a `dynamic` loop degrades to a + // no-op re-arm tick (same survival as the EIO case) rather than dying. + debugLoggerWarnSpy.mockClear(); + const eacces = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : [])) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + const errorEchoes = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('error]')); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The degraded no-op tick reached the model (the turn ran → no throw), + // carrying the dynamic re-arm sentinel and the EACCES errno note. + await vi.waitFor(() => { + expect(sentToModel()).toContain( + '# /loop tick — loop.md absent (dynamic pacing)', + ); + }); + expect(sentToModel()).toContain('<>'); + expect(sentToModel()).toContain( + 'could not be read this tick (EACCES)', + ); + // The loop did NOT surface an error (it survived). + expect(errorEchoes()).toHaveLength(0); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=dynamic, code=EACCES) — check .qwen/loop.md permissions/IO', + eacces, + ); + } finally { + resolveSpy.mockRestore(); + } + }); + + it('re-throws (does NOT degrade) a dynamic loop on a NON-fs resolve error', async () => { + // The gate's reason for existing: a non-transient error (a TypeError / + // programming bug → code 'unknown') is NOT in TRANSIENT_FS_CODES, so the + // `dynamic` branch must NOT degrade to an infinite silent no-op cycle. It + // falls through to the sanitized throw so the real bug surfaces. + // Mutation guard: drop the `&& TRANSIENT_FS_CODES.includes(code)` gate and + // 'unknown' degrades — a `# /loop tick` reaches the model and no + // `[loop error]` surfaces, failing both assertions below. + debugLoggerWarnSpy.mockClear(); + const bug = new TypeError( + "Cannot read properties of undefined (reading 'x')", + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(bug); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + const loopErrorTexts = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('[loop error]')); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The unexpected error surfaced (the loop did NOT silently degrade). + await vi.waitFor(() => + expect(loopErrorTexts().length).toBeGreaterThan(0), + ); + for (const text of loopErrorTexts()) { + // Sanitized: carries the 'unknown' errno, not the raw TypeError text. + expect(text).toContain('loop.md resolution failed (unknown)'); + expect(text).not.toContain('Cannot read properties'); + } + // No degraded tick was ever sent to the model. + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + expect(sentToModel()).not.toContain('# /loop tick'); + // The real (unsanitized) bug is still recorded in the LOCAL debug warn. + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=dynamic, code=unknown) — check .qwen/loop.md permissions/IO', + bug, + ); + } finally { + resolveSpy.mockRestore(); + } + }); + + it('still throws on a transient EACCES resolve error for a cron loop', async () => { + // The cron counterpart: cron re-fires on its own next interval, so even a + // known-transient EACCES STILL propagates (sanitized) rather than degrading. + debugLoggerWarnSpy.mockClear(); + const eacces = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eacces); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '*/5 * * * *' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const cronErrorTexts = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('[cron error]')); + await vi.waitFor(() => + expect(cronErrorTexts().length).toBeGreaterThan(0), + ); + for (const text of cronErrorTexts()) { + expect(text).toContain('EACCES'); + } + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => + Array.isArray(c[1]?.message) ? c[1].message : [], + ) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + expect(sentToModel()).not.toContain('# /loop tick'); + } finally { + resolveSpy.mockRestore(); + } + }); + it('echoes the absent label when a sentinel fires with no loop.md present', async () => { // The `loopTick && !loopTick.sourceLabel` branch: a sentinel fires but no // project or home loop.md exists, so the tick is a labelled no-op. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 60aa7600d8f..f7704c8ddc1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -217,6 +217,16 @@ const MAX_MID_TURN_RESOURCE_TEXT_LENGTH = 100_000; // conforming-but-busy client, while a client that never answers stops // costing a stall per tool batch after a few batches. const MID_TURN_QUEUE_DRAIN_MAX_TIMEOUT_STRIKES = 3; +// Known-transient fs error codes for loop.md sentinel resolution. A `dynamic` +// (self-paced) loop degrades to a no-op re-arm tick ONLY on these; any other +// (unexpected) error re-throws so a real bug surfaces instead of looping forever. +const TRANSIENT_FS_CODES: readonly string[] = [ + 'EACCES', + 'EIO', + 'EBUSY', + 'EPERM', + 'ENOENT', +]; type DrainedMidTurnMessage = | { kind: 'text'; message: string } @@ -2562,7 +2572,10 @@ export class Session implements SessionContext { `loop.md sentinel resolution failed (mode=${loopMode}, code=${code}) — check .qwen/loop.md permissions/IO`, resolveErr, ); - if (loopMode === 'dynamic') { + if ( + loopMode === 'dynamic' && + TRANSIENT_FS_CODES.includes(code) + ) { // A `dynamic` (self-paced) loop is kept alive ONLY by the // model re-arming LoopWakeup at the end of each turn; the // firing wakeup was already consumed, so throwing here (no @@ -2573,14 +2586,21 @@ export class Session implements SessionContext { // (`cron` re-fires on its own next interval, so it still // throws below.) The captured trust names the SAME candidate // set the probe used; the errno (no absolute path) is noted. + // Only KNOWN-transient codes degrade: an unexpected error + // (TypeError / assertion → code 'unknown') falls through to the + // throw so the real bug surfaces instead of an infinite no-op + // cycle. loopTick = resolver.buildTransientErrorTick( loopMode, trustedAtResolve, code, ); } else { - // Re-throw a SANITIZED error: the outer cron catch forwards - // error.message verbatim to the client via emitAgentMessage, + // Reached by `cron` (re-fires on its own next interval) and by + // `dynamic` with an UNEXPECTED (non-transient) error — both + // surface rather than silently degrade. Re-throw a SANITIZED + // error: the outer catch forwards error.message verbatim to the + // client via emitAgentMessage, // so re-throwing the raw fs error would leak that absolute // path. Surface only the candidate labels + errno code via the // shared absentLocations() — reusing the QWEN_HOME-aware home @@ -2605,7 +2625,9 @@ export class Session implements SessionContext { : loopTick.sourceLabel ? 'reminder' : 'absent' - } source=${loopTick.sourceLabel ?? 'none'}`, + } source=${loopTick.sourceLabel ?? 'none'} transient=${ + loopTick.transientError ?? false + }`, ); } // For a loop tick echo a stable, relative label — never the bare From 73df2ef367ed970623ebe7bc20b422a003a7a7da Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 29 Jun 2026 02:50:41 +0800 Subject: [PATCH 29/31] fix(loop): treat EISDIR/ENOTDIR as transient in dynamic mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readLoopTaskFile's lstat→open sequence can throw EISDIR/ENOTDIR if the loop.md path is swapped to a directory (or non-directory) between the pre-open lstat and fs.open (a narrow TOCTOU race). Add both codes to TRANSIENT_FS_CODES as defense-in-depth so a dynamic (self-paced) loop degrades to a no-op re-arm tick and survives, rather than re-throwing and silently terminating, should readLoopTaskFile's internal skip ever narrow. Drop ENOENT from the set: readLoopTaskFile resolves a missing file to its own missing→no-op path, so ENOENT can never reach the Session resolve() catch where TRANSIENT_FS_CODES is checked — listing it was dead and misleading ("absent" is not a transient read failure). Add dynamic-mode tests asserting an EISDIR and an ENOTDIR resolve error each degrade to a no-op re-arm tick (loop survives), mirroring the EACCES case. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 143 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 15 +- 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 10ff30d7d4d..d4fc75409eb 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5562,6 +5562,149 @@ describe('Session', () => { } }); + it('keeps a dynamic loop alive on a transient EISDIR resolve error', async () => { + // EISDIR is in TRANSIENT_FS_CODES (the lstat→open TOCTOU race: the path is + // swapped to a directory between the pre-open lstat and fs.open). A + // `dynamic` loop must degrade to a no-op re-arm tick — same survival as the + // EACCES/EIO cases — instead of dying. Mutation guard: drop EISDIR from the + // set and this throw falls through to the sanitized `[loop error]` re-throw. + debugLoggerWarnSpy.mockClear(); + const eisdir = Object.assign( + new Error('EISDIR: illegal operation on a directory, read'), + { code: 'EISDIR' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(eisdir); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : [])) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + const errorEchoes = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('error]')); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // The degraded no-op tick reached the model (the turn ran → no throw), + // carrying the dynamic re-arm sentinel and the EISDIR errno note. + await vi.waitFor(() => { + expect(sentToModel()).toContain( + '# /loop tick — loop.md absent (dynamic pacing)', + ); + }); + expect(sentToModel()).toContain('<>'); + expect(sentToModel()).toContain( + 'could not be read this tick (EISDIR)', + ); + // The loop did NOT surface an error (it survived). + expect(errorEchoes()).toHaveLength(0); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=dynamic, code=EISDIR) — check .qwen/loop.md permissions/IO', + eisdir, + ); + } finally { + resolveSpy.mockRestore(); + } + }); + + it('keeps a dynamic loop alive on a transient ENOTDIR resolve error', async () => { + // ENOTDIR is the sibling TOCTOU code (a path component swapped to a + // non-directory between the lstat and fs.open). Like EISDIR it must degrade + // a `dynamic` loop to a no-op re-arm tick rather than killing it. + debugLoggerWarnSpy.mockClear(); + const enotdir = Object.assign( + new Error('ENOTDIR: not a directory, open'), + { code: 'ENOTDIR' }, + ); + const resolveSpy = vi + .spyOn(core.LoopTickResolver.prototype, 'resolve') + .mockRejectedValue(enotdir); + + const scheduler = { + size: 1, + hasPendingWork: true, + start: vi.fn( + ( + callback: (job: { prompt: string; cronExpr?: string }) => void, + ) => { + callback({ prompt: '<>', cronExpr: '@wakeup' }); + }, + ), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(() => Promise.resolve(createEmptyStream())); + + const sentToModel = () => + (mockChat.sendMessageStream as ReturnType).mock.calls + .flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : [])) + .map((p: { text?: string }) => p.text ?? '') + .join(''); + const errorEchoes = () => + (mockClient.sessionUpdate as ReturnType).mock.calls + .map((call) => call[0]?.update) + .filter((u) => u?.sessionUpdate === 'agent_message_chunk') + .map((u) => u?.content?.text ?? '') + .filter((text: string) => text.includes('error]')); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(sentToModel()).toContain( + '# /loop tick — loop.md absent (dynamic pacing)', + ); + }); + expect(sentToModel()).toContain('<>'); + expect(sentToModel()).toContain( + 'could not be read this tick (ENOTDIR)', + ); + expect(errorEchoes()).toHaveLength(0); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'loop.md sentinel resolution failed (mode=dynamic, code=ENOTDIR) — check .qwen/loop.md permissions/IO', + enotdir, + ); + } finally { + resolveSpy.mockRestore(); + } + }); + it('re-throws (does NOT degrade) a dynamic loop on a NON-fs resolve error', async () => { // The gate's reason for existing: a non-transient error (a TypeError / // programming bug → code 'unknown') is NOT in TRANSIENT_FS_CODES, so the diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f7704c8ddc1..dd4d4917793 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -217,15 +217,22 @@ const MAX_MID_TURN_RESOURCE_TEXT_LENGTH = 100_000; // conforming-but-busy client, while a client that never answers stops // costing a stall per tool batch after a few batches. const MID_TURN_QUEUE_DRAIN_MAX_TIMEOUT_STRIKES = 3; -// Known-transient fs error codes for loop.md sentinel resolution. A `dynamic` -// (self-paced) loop degrades to a no-op re-arm tick ONLY on these; any other -// (unexpected) error re-throws so a real bug surfaces instead of looping forever. +// fs codes that let a `dynamic` (self-paced) loop treat a THROWN loop.md +// sentinel-resolution as transient — degrade to a no-op re-arm tick so the loop +// survives — instead of re-throwing (which ends it: the firing wakeup is already +// consumed, so only an end-of-turn re-arm keeps it alive). readLoopTaskFile only +// re-throws EACCES/EIO/EBUSY/EPERM (it skips ENOENT/EISDIR/ENOTDIR/ELOOP/… to its +// own `missing` → no-op path); EISDIR/ENOTDIR stay here as defense-in-depth for +// the lstat→open TOCTOU race (path swapped to a dir/non-dir mid-read) should that +// internal skip ever narrow. ENOENT is omitted on purpose: "absent" is not a +// transient read failure and can never reach this catch. const TRANSIENT_FS_CODES: readonly string[] = [ 'EACCES', 'EIO', 'EBUSY', 'EPERM', - 'ENOENT', + 'EISDIR', + 'ENOTDIR', ]; type DrainedMidTurnMessage = From 01983074165b9e7638194e6215050dd2d7cc926e Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 29 Jun 2026 04:46:43 +0800 Subject: [PATCH 30/31] test(loop): lock the setSkipDurableFire sentinel-predicate wiring in runNonInteractive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves of the headless durable-loop.md guard are tested alone — detectLoopSentinel via the skipHeadlessLoopSentinel tests, and the setSkipDurableFire filter in cronScheduler tests — but nothing pinned that runNonInteractive actually installs a predicate connecting them. Add a locking test that enables cron, injects a real CronScheduler, spies on setSkipDurableFire, and asserts the captured predicate classifies both loop.md sentinels (true) and a regular cron prompt (false). Co-Authored-By: Qwen-Coder --- packages/cli/src/nonInteractiveCli.test.ts | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index f6e2c8e4072..a709991d1af 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -3211,6 +3211,42 @@ describe('runNonInteractive', () => { ); }); + it('installs a skipDurableFire predicate that classifies loop.md sentinels in headless mode', async () => { + // Locks the wiring at the scheduler-enable site: runNonInteractive must + // hand the scheduler a predicate that skips durable loop.md sentinels + // (which a headless run can't expand), while still letting non-sentinel + // durable jobs fire. Both halves are covered alone — detectLoopSentinel via + // skipHeadlessLoopSentinel above, the filter via cronScheduler tests — but + // nothing pins that runNonInteractive actually connects them. A refactor + // dropping or rewriting this call would otherwise silently fire raw + // `<>` sentinels at the model (or skip real durable jobs), uncaught. + setupMetricsMock(); + // Real scheduler with no projectRoot: enableDurable() short-circuits (no + // filesystem/lock work) and, with no jobs, the headless cron hold-open + // resolves immediately, so runNonInteractive returns without hanging. + const scheduler = new CronScheduler(); + const skipSpy = vi.spyOn(scheduler, 'setSkipDurableFire'); + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'ok' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]), + ); + + await runNonInteractive(mockConfig, mockSettings, 'test', 'p-cron-wiring'); + + expect(skipSpy).toHaveBeenCalledOnce(); + const predicate = skipSpy.mock.calls[0][0]; + expect(predicate({ prompt: LOOP_SENTINEL_CRON } as CronJob)).toBe(true); + expect(predicate({ prompt: LOOP_SENTINEL_DYNAMIC } as CronJob)).toBe(true); + expect(predicate({ prompt: 'regular cron job' } as CronJob)).toBe(false); + }); + describe('--json-schema structured output', () => { // Helper: walk an emitted event and extract the first tool_use_id when // it represents a tool_result block. Returns undefined for any other From 4ab4012527579c165c9894ead92e2cab51816dac Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 29 Jun 2026 06:22:40 +0800 Subject: [PATCH 31/31] fix(loop): keep separator for root QWEN_HOME; distinct heading for transient-error tick Two maintainer-suggested refinements to the loop.md tick resolver: - homeLoopLabel() dropped the leading separator when $QWEN_HOME is the filesystem root: path.join('/', 'loop.md') = '/loop.md', whose path.dirname is '/' (length 1), so slicing past it yielded the garbled '$QWEN_HOMEloop.md'. The home candidate is always /loop.md, so build the label as '$QWEN_HOME' + path.sep + 'loop.md' directly -- byte-identical for the under-$HOME, non-root, and trailing-slash cases, fixed for root. - The transient-error tick (file exists but unreadable this tick, e.g. EIO) reused the ABSENT heading ('loop.md absent'), contradicting its own body ('could not be read this tick'). Give it a distinct 'loop.md unavailable' heading via a new tickHeading({ unavailable }) variant, keeping the dynamic re-arm tail and the sanitized (errno) note unchanged. Tests: add a root-$QWEN_HOME label case and assert the transient tick's heading conveys 'unavailable' (never 'absent'/'not present'); update the Session transient-tick heading assertions to match. The genuinely-absent tick still reads 'loop.md absent' / 'not present'. Co-Authored-By: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 8 ++-- .../bundled/loop/loop-tick-resolver.test.ts | 43 +++++++++++++++++-- .../skills/bundled/loop/loop-tick-resolver.ts | 34 +++++++++------ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index d4fc75409eb..f125aac7847 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5393,7 +5393,7 @@ describe('Session', () => { // The degraded no-op tick reached the model (the turn ran → no throw). await vi.waitFor(() => { expect(sentToModel()).toContain( - '# /loop tick — loop.md absent (dynamic pacing)', + '# /loop tick — loop.md unavailable (dynamic pacing)', ); }); // It carries the dynamic re-arm instruction (the literal sentinel) and @@ -5544,7 +5544,7 @@ describe('Session', () => { // carrying the dynamic re-arm sentinel and the EACCES errno note. await vi.waitFor(() => { expect(sentToModel()).toContain( - '# /loop tick — loop.md absent (dynamic pacing)', + '# /loop tick — loop.md unavailable (dynamic pacing)', ); }); expect(sentToModel()).toContain('<>'); @@ -5618,7 +5618,7 @@ describe('Session', () => { // carrying the dynamic re-arm sentinel and the EISDIR errno note. await vi.waitFor(() => { expect(sentToModel()).toContain( - '# /loop tick — loop.md absent (dynamic pacing)', + '# /loop tick — loop.md unavailable (dynamic pacing)', ); }); expect(sentToModel()).toContain('<>'); @@ -5688,7 +5688,7 @@ describe('Session', () => { await vi.waitFor(() => { expect(sentToModel()).toContain( - '# /loop tick — loop.md absent (dynamic pacing)', + '# /loop tick — loop.md unavailable (dynamic pacing)', ); }); expect(sentToModel()).toContain('<>'); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index a54dbeb06c3..9a3354d4947 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -331,18 +331,23 @@ describe('LoopTickResolver', () => { it('buildTransientErrorTick mirrors the absent tick with a re-arm and errno note', () => { // FIX 4: a transient, non-whitelisted read error must NOT kill a dynamic - // loop. The degraded tick mirrors the absent path — same heading + the - // dynamic re-arm sentinel — plus a note that the file was unreadable this - // tick, so the model still re-arms LoopWakeup and the loop survives. + // loop. The degraded tick mirrors the absent path's re-arm + cache-clear, plus + // a note that the file was unreadable this tick, so the model still re-arms + // LoopWakeup and the loop survives. const tick = resolver.buildTransientErrorTick('dynamic', true, 'EIO'); expect(tick.full).toBe(false); // Flagged transient (file present, unreadable this tick) so the caller's echo // can say "temporarily unavailable" rather than the genuinely-absent label. expect(tick.transientError).toBe(true); + // The heading says "unavailable", NOT "absent"/"not present": the file exists, + // it just couldn't be read this tick, so the heading must mirror the body. + // Mutation guard: revert the heading to { absent: true } and these fail. expect(tick.modelText).toContain( - '# /loop tick — loop.md absent (dynamic pacing)\n', + '# /loop tick — loop.md unavailable (dynamic pacing)\n', ); + expect(tick.modelText).not.toContain('absent'); + expect(tick.modelText).not.toContain('not present'); expect(tick.modelText).toContain('could not be read this tick (EIO)'); // The dynamic re-arm instruction (the literal sentinel) keeps the loop alive. expect(tick.modelText).toContain(LOOP_SENTINEL_DYNAMIC); @@ -357,6 +362,10 @@ describe('LoopTickResolver', () => { // (untrusted) the never-probed project candidate must NOT be named. const tick = resolver.buildTransientErrorTick('cron', false, 'EACCES'); + // Heading conveys "unavailable" (file exists, unreadable this tick), never + // the misleading "absent". + expect(tick.modelText).toContain('# /loop tick — loop.md unavailable'); + expect(tick.modelText).not.toContain('absent'); expect(tick.modelText).toContain('could not be read this tick (EACCES)'); expect(tick.modelText).toContain('the recurring cron fires the next tick'); expect(tick.modelText).not.toContain(LOOP_SENTINEL_DYNAMIC); @@ -522,6 +531,32 @@ describe('LoopTickResolver', () => { expect(underHome.homeLoopLabel()).not.toContain(os.homedir()); }); + it('homeLoopLabel keeps the separator when $QWEN_HOME is the filesystem root', async () => { + // `QWEN_HOME=/` makes homeQwenDir the root, so homeLoopPath is + // path.join('/', 'loop.md') = '/loop.md', whose path.dirname is '/' (length 1). + // Slicing the joined path past that length drops the leading separator, + // garbling the label into the separator-less `$QWEN_HOMEloop.md`. Mutation + // guard: revert homeLoopLabel to the slice-by-dirname-length approach and the + // first assertion below fails with `$QWEN_HOMEloop.md`. + const root = path.sep; // the filesystem root ('/' on POSIX) + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = root; + try { + const atRoot = new LoopTickResolver({ + projectRoot, + homeDir: root, + homeQwenDir: root, + allowProjectFile: () => true, + }); + expect(atRoot.homeLoopLabel()).toBe(`$QWEN_HOME${path.sep}loop.md`); + // The garbled, separator-less form must never appear. + expect(atRoot.homeLoopLabel()).not.toContain('$QWEN_HOMEloop.md'); + } finally { + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + } + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 2f476368ef1..28f64469236 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -98,13 +98,17 @@ const SHORT_REMINDER_PREAMBLE = */ function tickHeading( mode: LoopMode, - opts: { sourceLabel?: string; absent?: boolean } = {}, + opts: { sourceLabel?: string; absent?: boolean; unavailable?: boolean } = {}, ): string { - const subject = opts.absent - ? 'loop.md absent' - : opts.sourceLabel - ? `loop.md tasks from ${opts.sourceLabel}` - : 'loop.md tasks'; + // `unavailable` (transient read failure) is distinct from `absent`: the file + // exists but couldn't be read THIS tick, so the heading must not claim it's gone. + const subject = opts.unavailable + ? 'loop.md unavailable' + : opts.absent + ? 'loop.md absent' + : opts.sourceLabel + ? `loop.md tasks from ${opts.sourceLabel}` + : 'loop.md tasks'; const base = `# /loop tick — ${subject}`; return mode === 'dynamic' ? `${base} (dynamic pacing)` : base; } @@ -213,13 +217,15 @@ export class LoopTickResolver { } // Outside $HOME: tildeifyPath was a no-op. When $QWEN_HOME relocated the // global dir (homeQwenDir is its resolved value), report the literal env-var - // name by swapping the resolved prefix — never the absolute path. Slice past - // path.dirname(homeLoopPath), not homeQwenDir.length: Storage.getGlobalQwenDir() - // doesn't strip a trailing slash, so `$QWEN_HOME=/x/.qwen/` reaches here as - // `/x/.qwen/` and its length over-counts the separator, garbling the tail into - // `$QWEN_HOMEloop.md`. dirname of the joined path is always trailing-slash-free. + // name — never the absolute path. The home candidate is always + // `/loop.md`, so swap the whole resolved dir for `$QWEN_HOME` and + // re-attach the separator + basename directly. Deriving the tail from the + // resolved path's length instead mishandles edge dirs: a trailing slash + // (`$QWEN_HOME=/x/.qwen/`) over-counts the separator, and a filesystem-root + // homeQwenDir (`$QWEN_HOME=/` → homeLoopPath `/loop.md`, dirname `/`) drops the + // leading separator — both garbling the tail into `$QWEN_HOMEloop.md`. if (process.env['QWEN_HOME']) { - return `$QWEN_HOME${homeLoopPath.slice(path.dirname(homeLoopPath).length)}`; + return `$QWEN_HOME${path.sep}loop.md`; } return 'the configured global loop.md'; } @@ -266,7 +272,9 @@ export class LoopTickResolver { code: string, ): LoopTickResult { return this.#noOpTick( - `${tickHeading(mode, { absent: true })}\nloop.md at ${this.absentLocations( + // `unavailable`, not `absent`: the file exists but was unreadable this tick, + // so the heading mirrors the body instead of contradicting it. + `${tickHeading(mode, { unavailable: true })}\nloop.md at ${this.absentLocations( projectChecked, )} could not be read this tick (${code}). ${ABSENT_TAIL[mode]}`, // Flag the tick as a transient read failure (file exists, unreadable this