fix: recover legacy Windows session workdirs

This commit is contained in:
Relakkes Yang 2026-05-18 13:13:12 +08:00
parent cf7c4487f0
commit 3c3030fddf
12 changed files with 115 additions and 45 deletions

View File

@ -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) + '…'

View File

@ -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

View File

@ -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(/[\\/]+$/, '')
}

View File

@ -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', () => {

View File

@ -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 })

View File

@ -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)
})

View File

@ -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

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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
}

View File

@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise<string> {
return realPath
}
async function canonicalizeKnownPath(candidate: string): Promise<string> {
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<RepositoryC
])
const currentBranch = branchResult.stdout.trim() || null
const worktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
const rawWorktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
const worktreeRecords = await Promise.all(
rawWorktreeRecords.map(async (worktree) => ({
...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 {

View File

@ -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)
}