From 3c3030fddfd1be92f286c7a44c4360c31b863450 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Mon, 18 May 2026 13:13:12 +0800 Subject: [PATCH] fix: recover legacy Windows session workdirs --- adapters/feishu/__tests__/feishu.test.ts | 10 +---- adapters/feishu/index.ts | 11 +---- adapters/feishu/path-safety.ts | 23 +++++++++++ desktop/src/theme/globals.test.ts | 18 +++++---- scripts/git-hooks/install.test.ts | 1 + src/server/__tests__/adapters.test.ts | 4 ++ src/server/__tests__/conversations.test.ts | 5 ++- src/server/__tests__/sessions.test.ts | 40 +++++++++++++++++-- src/server/__tests__/teams.test.ts | 6 +-- src/server/api/sessions.ts | 7 ++-- .../services/repositoryLaunchService.ts | 23 ++++++++++- src/server/services/sessionService.ts | 12 ++++-- 12 files changed, 115 insertions(+), 45 deletions(-) create mode 100644 adapters/feishu/path-safety.ts diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts index cd13e582..14377639 100644 --- a/adapters/feishu/__tests__/feishu.test.ts +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'bun:test' -import * as path from 'node:path' +import { isOutsideWorkDir } from '../path-safety.js' // ---------- helpers extracted from feishu/index.ts for testability ---------- @@ -209,14 +209,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { } } -function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) -} - function truncateTarget(s: string, maxLen = 160): string { if (s.length <= maxLen) return s return s.slice(0, maxLen - 1) + '…' diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 9fa535e9..bea9e226 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -37,6 +37,7 @@ import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import type { PendingUpload } from '../common/attachment/attachment-types.js' +import { isOutsideWorkDir } from './path-safety.js' // ---------- init ---------- @@ -502,16 +503,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { } } -/** True if `filePath` resolves to a location outside of `workDir`. - * Relative paths are resolved against workDir first. */ -function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) -} - /** Truncate a single-line target preview (e.g. shell command) to maxLen. */ function truncateTarget(s: string, maxLen = 160): string { if (s.length <= maxLen) return s diff --git a/adapters/feishu/path-safety.ts b/adapters/feishu/path-safety.ts new file mode 100644 index 00000000..4b722755 --- /dev/null +++ b/adapters/feishu/path-safety.ts @@ -0,0 +1,23 @@ +import * as path from 'node:path' + +/** True if `filePath` resolves to a location outside of `workDir`. + * Relative paths are resolved against workDir first. */ +export function isOutsideWorkDir(filePath: string, workDir: string): boolean { + const pathApi = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix + const abs = pathApi.isAbsolute(filePath) + ? pathApi.normalize(filePath) + : pathApi.resolve(workDir, filePath) + const normWork = stripTrailingSeparators(pathApi.normalize(workDir), pathApi) + const relative = pathApi.relative(normWork, abs) + return relative !== '' && (relative.startsWith('..') || pathApi.isAbsolute(relative)) +} + +function usesWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +function stripTrailingSeparators(value: string, pathApi: typeof path.posix | typeof path.win32): string { + const root = pathApi.parse(value).root + if (value === root) return value + return value.replace(/[\\/]+$/, '') +} diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index 6584d978..218ab394 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -2,19 +2,21 @@ import { describe, expect, it } from 'vitest' import css from './globals.css?raw' +const normalizedCss = css.replace(/\r\n/g, '\n') + function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="white"]' | '[data-theme="dark"]') { - const start = css.indexOf(`${selector} {`) + const start = normalizedCss.indexOf(`${selector} {`) expect(start).toBeGreaterThanOrEqual(0) - const bodyStart = css.indexOf('{', start) + const bodyStart = normalizedCss.indexOf('{', start) let depth = 0 - for (let index = bodyStart; index < css.length; index += 1) { - const char = css[index] + for (let index = bodyStart; index < normalizedCss.length; index += 1) { + const char = normalizedCss[index] if (char === '{') depth += 1 if (char === '}') { depth -= 1 if (depth === 0) { - return css.slice(bodyStart + 1, index) + return normalizedCss.slice(bodyStart + 1, index) } } } @@ -23,11 +25,11 @@ function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme=" } function getCssBetween(startMarker: string, endMarker: string) { - const start = css.indexOf(startMarker) + const start = normalizedCss.indexOf(startMarker) expect(start).toBeGreaterThanOrEqual(0) - const end = css.indexOf(endMarker, start) + const end = normalizedCss.indexOf(endMarker, start) expect(end).toBeGreaterThan(start) - return css.slice(start, end) + return normalizedCss.slice(start, end) } describe('desktop theme tokens', () => { diff --git a/scripts/git-hooks/install.test.ts b/scripts/git-hooks/install.test.ts index 8941d315..e15c193d 100644 --- a/scripts/git-hooks/install.test.ts +++ b/scripts/git-hooks/install.test.ts @@ -35,6 +35,7 @@ describe('installPrePushHook', () => { expect(result.hookPath).toBe(hookPath) expect(result.liveConfigured).toBe(false) expect(readFileSync(hookPath, 'utf8')).toContain('echo quality') + if (process.platform === 'win32') return expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0) } finally { rmSync(tempDir, { recursive: true, force: true }) diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index fd3c7d11..7cdf3ea6 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -67,6 +67,10 @@ describe('Adapters API', () => { const configPath = path.join(tmpDir, 'adapters.json') const stat = await fs.stat(configPath) + if (process.platform === 'win32') { + expect(stat.isFile()).toBe(true) + return + } expect(stat.mode & 0o777).toBe(0o600) }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index db685408..11251e8d 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -763,9 +763,9 @@ describe('WebSocket Chat Integration', () => { }) afterAll(async () => { - server?.stop() + server?.stop(true) if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }) + await rmWithRetry(tmpDir) } if (originalCliPath) { process.env.CLAUDE_CLI_PATH = originalCliPath @@ -906,6 +906,7 @@ describe('WebSocket Chat Integration', () => { workDir: worktreePath!, repository: launchInfo!.repository, }) + await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!) const messages = await runTurn(sessionId, 'Continue in the existing worktree') const statusVerbs = messages diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index ffd0eee9..41c4e162 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1226,6 +1226,20 @@ describe('SessionService', () => { expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) + it('should keep stale worktree records when their paths cannot be resolved', async () => { + const workDir = await createCleanGitRepo(tmpDir) + const staleWorktree = path.join(tmpDir, `stale-worktree-${Date.now()}`) + git(workDir, 'worktree', 'add', '-b', 'stale-worktree', staleWorktree, 'feature/rail') + await fs.rm(staleWorktree, { recursive: true, force: true }) + + const context = await getRepositoryContext(workDir) + const expectedPath = path.resolve(staleWorktree).normalize('NFC') + expect(context.state).toBe('ok') + expect(context.worktrees.some((worktree) => ( + worktree.path === expectedPath && worktree.branch === 'stale-worktree' && !worktree.current + ))).toBe(true) + }) + it('should let git carry compatible dirty changes during direct branch launch', async () => { const workDir = await createCleanGitRepo(tmpDir) await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') @@ -1515,6 +1529,26 @@ describe('SessionService', () => { expect(launchInfo!.transcriptMessageCount).toBe(2) expect(launchInfo!.customTitle).toBe('Saved chat') }) + + it('should recover Windows drive paths from sanitized project dirs for old transcripts without metadata', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff' + const userUuid = crypto.randomUUID() + const userEntry = makeUserEntry('Resume this Windows session', userUuid) + delete userEntry.cwd + await writeSessionFile('g--AI-NTos-NT-deepseek-nano-core', sessionId, [ + makeSnapshotEntry(), + userEntry, + makeAssistantEntry('Welcome back', userUuid), + ]) + + const expectedWorkDir = 'g:\\AI\\NTos\\NT\\deepseek\\nano\\core' + expect(await service.getSessionWorkDir(sessionId)).toBe(expectedWorkDir) + + const launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo).not.toBeNull() + expect(launchInfo!.workDir).toBe(expectedWorkDir) + expect(launchInfo!.transcriptMessageCount).toBe(2) + }) }) // ============================================================================ @@ -1533,11 +1567,8 @@ describe('Sessions API', () => { const { handleSessionsApi } = await import('../api/sessions.js') const { handleConversationsApi } = await import('../api/conversations.js') - const port = 30000 + Math.floor(Math.random() * 10000) - baseUrl = `http://127.0.0.1:${port}` - server = Bun.serve({ - port, + port: 0, hostname: '127.0.0.1', async fetch(req) { @@ -1555,6 +1586,7 @@ describe('Sessions API', () => { return new Response('Not Found', { status: 404 }) }, }) + baseUrl = `http://127.0.0.1:${server.port}` }) afterEach(async () => { diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index d51ccfda..ca24335a 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -461,11 +461,8 @@ describe('Teams API', () => { const { handleTeamsApi } = await import('../api/teams.js') - const port = 40000 + Math.floor(Math.random() * 10000) - baseUrl = `http://127.0.0.1:${port}` - server = Bun.serve({ - port, + port: 0, hostname: '127.0.0.1', async fetch(req) { @@ -479,6 +476,7 @@ describe('Teams API', () => { return new Response('Not Found', { status: 404 }) }, }) + baseUrl = `http://127.0.0.1:${server.port}` }) afterEach(async () => { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 4fe19747..70ddda49 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -777,9 +777,10 @@ const RECENT_PROJECTS_CACHE_TTL = 30_000 const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' function projectNameForRecentPath(realPath: string, fallback: string): string { - const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER) - ? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER)) - : realPath + const normalizedRealPath = realPath.replace(/\\/g, '/') + const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER) + ? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER)) + : normalizedRealPath return displayRoot.split('/').filter(Boolean).pop() || fallback } diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index 4d059da9..7b7825bf 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise { return realPath } +async function canonicalizeKnownPath(candidate: string): Promise { + try { + return (await fs.realpath(candidate)).normalize('NFC') + } catch { + return path.resolve(candidate).normalize('NFC') + } +} + +function isSameOrInsidePath(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate) + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) +} + function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null { if (!ref || ref.endsWith('/HEAD')) return null const slash = ref.indexOf('/') @@ -353,11 +366,17 @@ export async function getRepositoryContext(workDir: string): Promise ({ + ...worktree, + path: await canonicalizeKnownPath(worktree.path), + })), + ) const worktrees = worktreeRecords.map((worktree) => ({ path: worktree.path, branch: worktree.branch, - current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`), + current: isSameOrInsidePath(worktree.path, absWorkDir), })) return { diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 0e651d75..b4db4fcd 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -950,10 +950,16 @@ export class SessionService { * Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`. */ desanitizePath(sanitized: string): string { - // The sanitized form replaces all '/' (and '\') with '-'. + // The sanitized form replaces all non-alphanumeric characters with '-'. + // This fallback is necessarily lossy, but old Windows transcripts without + // session-meta still need the drive separator restored well enough to resume. + const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/) + if (windowsDrivePath) { + return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}` + } + // On POSIX the original path starts with '/', so the sanitized form starts with '-'. - // We restore by replacing every '-' with '/' (the platform separator). - // On Windows the leading character would be a drive letter, but we handle POSIX here. + // UNC-style Windows paths also recover to a leading double separator on Windows. return sanitized.replace(/-/g, path.sep) }