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 { 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 ---------- // ---------- 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 { function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s if (s.length <= maxLen) return s
return s.slice(0, maxLen - 1) + '…' 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 { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
import type { PendingUpload } from '../common/attachment/attachment-types.js' import type { PendingUpload } from '../common/attachment/attachment-types.js'
import { isOutsideWorkDir } from './path-safety.js'
// ---------- init ---------- // ---------- 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. */ /** Truncate a single-line target preview (e.g. shell command) to maxLen. */
function truncateTarget(s: string, maxLen = 160): string { function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s 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' 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"]') { 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) expect(start).toBeGreaterThanOrEqual(0)
const bodyStart = css.indexOf('{', start) const bodyStart = normalizedCss.indexOf('{', start)
let depth = 0 let depth = 0
for (let index = bodyStart; index < css.length; index += 1) { for (let index = bodyStart; index < normalizedCss.length; index += 1) {
const char = css[index] const char = normalizedCss[index]
if (char === '{') depth += 1 if (char === '{') depth += 1
if (char === '}') { if (char === '}') {
depth -= 1 depth -= 1
if (depth === 0) { 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) { function getCssBetween(startMarker: string, endMarker: string) {
const start = css.indexOf(startMarker) const start = normalizedCss.indexOf(startMarker)
expect(start).toBeGreaterThanOrEqual(0) expect(start).toBeGreaterThanOrEqual(0)
const end = css.indexOf(endMarker, start) const end = normalizedCss.indexOf(endMarker, start)
expect(end).toBeGreaterThan(start) expect(end).toBeGreaterThan(start)
return css.slice(start, end) return normalizedCss.slice(start, end)
} }
describe('desktop theme tokens', () => { describe('desktop theme tokens', () => {

View File

@ -35,6 +35,7 @@ describe('installPrePushHook', () => {
expect(result.hookPath).toBe(hookPath) expect(result.hookPath).toBe(hookPath)
expect(result.liveConfigured).toBe(false) expect(result.liveConfigured).toBe(false)
expect(readFileSync(hookPath, 'utf8')).toContain('echo quality') expect(readFileSync(hookPath, 'utf8')).toContain('echo quality')
if (process.platform === 'win32') return
expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0) expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0)
} finally { } finally {
rmSync(tempDir, { recursive: true, force: true }) rmSync(tempDir, { recursive: true, force: true })

View File

@ -67,6 +67,10 @@ describe('Adapters API', () => {
const configPath = path.join(tmpDir, 'adapters.json') const configPath = path.join(tmpDir, 'adapters.json')
const stat = await fs.stat(configPath) const stat = await fs.stat(configPath)
if (process.platform === 'win32') {
expect(stat.isFile()).toBe(true)
return
}
expect(stat.mode & 0o777).toBe(0o600) expect(stat.mode & 0o777).toBe(0o600)
}) })

View File

@ -763,9 +763,9 @@ describe('WebSocket Chat Integration', () => {
}) })
afterAll(async () => { afterAll(async () => {
server?.stop() server?.stop(true)
if (tmpDir) { if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true }) await rmWithRetry(tmpDir)
} }
if (originalCliPath) { if (originalCliPath) {
process.env.CLAUDE_CLI_PATH = originalCliPath process.env.CLAUDE_CLI_PATH = originalCliPath
@ -906,6 +906,7 @@ describe('WebSocket Chat Integration', () => {
workDir: worktreePath!, workDir: worktreePath!,
repository: launchInfo!.repository, repository: launchInfo!.repository,
}) })
await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!)
const messages = await runTurn(sessionId, 'Continue in the existing worktree') const messages = await runTurn(sessionId, 'Continue in the existing worktree')
const statusVerbs = messages const statusVerbs = messages

View File

@ -1226,6 +1226,20 @@ describe('SessionService', () => {
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) 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 () => { it('should let git carry compatible dirty changes during direct branch launch', async () => {
const workDir = await createCleanGitRepo(tmpDir) const workDir = await createCleanGitRepo(tmpDir)
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') 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!.transcriptMessageCount).toBe(2)
expect(launchInfo!.customTitle).toBe('Saved chat') 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 { handleSessionsApi } = await import('../api/sessions.js')
const { handleConversationsApi } = await import('../api/conversations.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({ server = Bun.serve({
port, port: 0,
hostname: '127.0.0.1', hostname: '127.0.0.1',
async fetch(req) { async fetch(req) {
@ -1555,6 +1586,7 @@ describe('Sessions API', () => {
return new Response('Not Found', { status: 404 }) return new Response('Not Found', { status: 404 })
}, },
}) })
baseUrl = `http://127.0.0.1:${server.port}`
}) })
afterEach(async () => { afterEach(async () => {

View File

@ -461,11 +461,8 @@ describe('Teams API', () => {
const { handleTeamsApi } = await import('../api/teams.js') 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({ server = Bun.serve({
port, port: 0,
hostname: '127.0.0.1', hostname: '127.0.0.1',
async fetch(req) { async fetch(req) {
@ -479,6 +476,7 @@ describe('Teams API', () => {
return new Response('Not Found', { status: 404 }) return new Response('Not Found', { status: 404 })
}, },
}) })
baseUrl = `http://127.0.0.1:${server.port}`
}) })
afterEach(async () => { afterEach(async () => {

View File

@ -777,9 +777,10 @@ const RECENT_PROJECTS_CACHE_TTL = 30_000
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function projectNameForRecentPath(realPath: string, fallback: string): string { function projectNameForRecentPath(realPath: string, fallback: string): string {
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER) const normalizedRealPath = realPath.replace(/\\/g, '/')
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER)) const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER)
: realPath ? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER))
: normalizedRealPath
return displayRoot.split('/').filter(Boolean).pop() || fallback return displayRoot.split('/').filter(Boolean).pop() || fallback
} }

View File

@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise<string> {
return realPath 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 { function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null {
if (!ref || ref.endsWith('/HEAD')) return null if (!ref || ref.endsWith('/HEAD')) return null
const slash = ref.indexOf('/') const slash = ref.indexOf('/')
@ -353,11 +366,17 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
]) ])
const currentBranch = branchResult.stdout.trim() || null 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) => ({ const worktrees = worktreeRecords.map((worktree) => ({
path: worktree.path, path: worktree.path,
branch: worktree.branch, branch: worktree.branch,
current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`), current: isSameOrInsidePath(worktree.path, absWorkDir),
})) }))
return { return {

View File

@ -950,10 +950,16 @@ export class SessionService {
* Reverses sanitizePath(): `-Users-nanmi-workspace` `/Users/nanmi/workspace`. * Reverses sanitizePath(): `-Users-nanmi-workspace` `/Users/nanmi/workspace`.
*/ */
desanitizePath(sanitized: string): string { 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 '-'. // On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// We restore by replacing every '-' with '/' (the platform separator). // UNC-style Windows paths also recover to a leading double separator on Windows.
// On Windows the leading character would be a drive letter, but we handle POSIX here.
return sanitized.replace(/-/g, path.sep) return sanitized.replace(/-/g, path.sep)
} }