mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
fix: recover legacy Windows session workdirs
This commit is contained in:
parent
cf7c4487f0
commit
3c3030fddf
@ -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) + '…'
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
23
adapters/feishu/path-safety.ts
Normal file
23
adapters/feishu/path-safety.ts
Normal 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(/[\\/]+$/, '')
|
||||||
|
}
|
||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 })
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user