From ca0f891c2b12bf1f426039de7342da9214530d51 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 28 Oct 2026 09:34:05 +0100 Subject: [PATCH] fix: never read a remote fix-plan path from the local disk (release review, #722) setRemoteWorkingDir() records the path for authority only, and today nothing else reads it for a remote session (loadFixPlanFromDisk's only callers are setWorkingDir and the local watcher). The watcher now remembers the remote mode and refuses both the local read and the local watch for it, so a future caller cannot import a same-named local twin. Co-Authored-By: Claude Opus 5.5 --- src/ralph-fix-plan-watcher.ts | 12 ++++++++--- test/ralph-fix-plan-remote.test.ts | 34 +++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/ralph-fix-plan-watcher.ts b/src/ralph-fix-plan-watcher.ts index 305f3b39..5347c107 100644 --- a/src/ralph-fix-plan-watcher.ts +++ b/src/ralph-fix-plan-watcher.ts @@ -203,6 +203,9 @@ export class RalphFixPlanWatcher extends EventEmitter { /** Path to the @fix_plan.md file being watched */ private _fixPlanPath: string | null = null; + /** The path is on a REMOTE host (`setRemoteWorkingDir`): never read or watch it locally. */ + private _remote = false; + /** File watcher for @fix_plan.md */ private _fixPlanWatcher: FSWatcher | null = null; @@ -238,6 +241,7 @@ export class RalphFixPlanWatcher extends EventEmitter { * @param workingDir - The session's working directory */ setWorkingDir(workingDir: string): void { + this._remote = false; this._workingDir = workingDir; this._fixPlanPath = join(workingDir, '@fix_plan.md'); @@ -258,16 +262,18 @@ export class RalphFixPlanWatcher extends EventEmitter { */ setRemoteWorkingDir(workingDir: string): void { this.stopWatchingFixPlan(); + this._remote = true; this._workingDir = workingDir; this._fixPlanPath = join(workingDir, '@fix_plan.md'); } /** * Load @fix_plan.md from disk if it exists. - * Called on initialization and when file changes are detected. + * Called on initialization and when file changes are detected. A remote path is never read here: + * the local disk would hold a missing path or an unrelated same-named file. */ async loadFixPlanFromDisk(): Promise { - if (!this._fixPlanPath) return 0; + if (!this._fixPlanPath || this._remote) return 0; try { if (!existsSync(this._fixPlanPath)) { @@ -295,7 +301,7 @@ export class RalphFixPlanWatcher extends EventEmitter { * Reloads todos when the file is modified. */ private startWatchingFixPlan(): void { - if (!this._fixPlanPath || !this._workingDir) return; + if (!this._fixPlanPath || !this._workingDir || this._remote) return; // Stop existing watcher if any this.stopWatchingFixPlan(); diff --git a/test/ralph-fix-plan-remote.test.ts b/test/ralph-fix-plan-remote.test.ts index c499b65e..661b7e5e 100644 --- a/test/ralph-fix-plan-remote.test.ts +++ b/test/ralph-fix-plan-remote.test.ts @@ -18,7 +18,7 @@ * Drives the real `WebServer.setupSessionListeners()`, like * discard-partially-built-session.test.ts. */ -import { mkdirSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -100,6 +100,38 @@ describe('Ralph @fix_plan.md watcher and the session location', () => { expect(session.ralphTracker.isFileAuthoritative).toBe(true); }); + it('never reads a same-named local file for a remote session', async () => { + // The remote path exists locally too (an sshfs mount, a leftover checkout) and holds a plan. + writeFileSync(join(WORKSPACE, '@fix_plan.md'), '- [ ] a local task the remote session never had\n'); + try { + const session = new Session({ + id: SESSION_ID, + workingDir: WORKSPACE, + mode: 'claude', + name: 'remote session over a local twin', + mux, + useMux: true, + remote: { + hostId: 'proxx', + label: 'proxx', + host: 'proxx.example.lan', + username: 'user', + remotePath: WORKSPACE, + owned: true, + }, + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(await session.ralphTracker.loadFixPlanFromDisk()).toBe(0); + expect(session.ralphTracker.todos).toEqual([]); + } finally { + rmSync(join(WORKSPACE, '@fix_plan.md'), { force: true }); + } + }); + it('keeps a remote session todo list out of the prose completion guessers', async () => { const session = buildSession(true); vi.spyOn(console, 'log').mockImplementation(() => {});