diff --git a/desktop/electron/services/shell.test.ts b/desktop/electron/services/shell.test.ts index d814fb79..8145c27b 100644 --- a/desktop/electron/services/shell.test.ts +++ b/desktop/electron/services/shell.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { normalizeExternalUrl, normalizeOpenPath, normalizeSystemSettingsUrl } from './shell' +import { expandTildePath, normalizeExternalUrl, normalizeOpenPath, normalizeSystemSettingsUrl } from './shell' describe('Electron shell service', () => { it('allows only explicit external URL schemes', () => { @@ -36,6 +36,20 @@ describe('Electron shell service', () => { expect(() => normalizeOpenPath('relative/report.md')).toThrow('absolute') }) + it('expands tilde paths per platform', () => { + expect(expandTildePath('~', 'darwin')).toBe(homedir()) + expect(expandTildePath('~/reports/a.html', 'linux')).toBe(`${homedir()}/reports/a.html`) + expect(expandTildePath('~\\reports\\a.html', 'win32')).toBe(`${homedir()}\\reports\\a.html`) + // On POSIX "~\..." is a regular file name; "~user" expansion is unsupported. + expect(expandTildePath('~\\reports\\a.html', 'darwin')).toBe('~\\reports\\a.html') + expect(expandTildePath('~user/file.md', 'linux')).toBe('~user/file.md') + expect(expandTildePath('a/~/b.md', 'linux')).toBe('a/~/b.md') + }) + + it('expands tilde paths before the absolute-path check in openPath', () => { + expect(normalizeOpenPath('~')).toBe(realpathSync(homedir())) + }) + it('allows only explicit system settings URLs', () => { expect(normalizeSystemSettingsUrl('ms-settings:notifications')).toBe('ms-settings:notifications') expect(normalizeSystemSettingsUrl('x-apple.systempreferences:com.apple.preference.notifications')).toBe( diff --git a/desktop/electron/services/shell.ts b/desktop/electron/services/shell.ts index c4bcb4c8..74c70eae 100644 --- a/desktop/electron/services/shell.ts +++ b/desktop/electron/services/shell.ts @@ -1,4 +1,5 @@ import { realpathSync, statSync } from 'node:fs' +import { homedir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -33,8 +34,25 @@ export function normalizeExternalUrl(target: string): string { return url.toString() } +/** + * Expands a leading tilde to the home directory. `~\` is only a tilde path on + * Windows — on POSIX the backslash is a valid filename character. + */ +export function expandTildePath(target: string, platform: NodeJS.Platform = process.platform): string { + if ( + target === '~' || + target.startsWith('~/') || + (platform === 'win32' && target.startsWith('~\\')) + ) { + return homedir() + target.slice(1) + } + return target +} + export function normalizeOpenPath(target: string): string { - const filePath = target.startsWith('file://') ? fileURLToPath(target) : target + const filePath = expandTildePath( + target.startsWith('file://') ? fileURLToPath(target) : target, + ) if (!path.isAbsolute(filePath)) { throw new Error('System file paths must be absolute') } diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index b294c0f6..31099e4f 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -50,6 +50,25 @@ describe('openWithContextForHref', () => { const result = openWithContextForHref('src/index.ts', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/proj/' }) expect(result).toEqual({ kind: 'file', absolutePath: '/proj/src/index.ts', relPath: 'src/index.ts', previewable: true }) }) + + it('tilde html path → absolutePath passed through, not joined onto workDir', () => { + const result = openWithContextForHref('~/reports/a.html', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/w' }) + expect(result).toEqual({ + kind: 'file', + absolutePath: '~/reports/a.html', + inAppBrowserUrl: previewFsUrl(BASE, SESSION, '~/reports/a.html'), + }) + }) + + it('Windows backslash tilde html path → absolutePath passed through', () => { + const result = openWithContextForHref('~\\reports\\a.html', { sessionId: SESSION, serverBaseUrl: BASE, workDir: 'C:/w' }) + expect(result).toMatchObject({ kind: 'file', absolutePath: '~\\reports\\a.html' }) + }) + + it('tilde markdown path → absolutePath passed through in file-preview context', () => { + const result = openWithContextForHref('~/notes/a.md', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/w' }) + expect(result).toEqual({ kind: 'file', absolutePath: '~/notes/a.md', relPath: '~/notes/a.md', previewable: true }) + }) }) describe('openWithContextForWorkspaceFile', () => { diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts index 28065b4a..3dce9eeb 100644 --- a/desktop/src/lib/openWithContextForHref.ts +++ b/desktop/src/lib/openWithContextForHref.ts @@ -19,6 +19,9 @@ export function openWithContextForWorkspaceFile( } function resolveAbsolute(workDir: string | undefined, p: string): string { + // Tilde paths are home-relative, not workspace-relative — pass them through + // for the backend (which knows the home dir and platform) to expand. + if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) return p if (!workDir || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return p return `${workDir.replace(/[\\/]+$/, '')}/${p.replace(/^[/\\]+/, '')}` } diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 12410eb0..1dec49f7 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { createOpenTargetService } from '../services/openTargetService.js' async function makeDir(prefix = 'cc-haha-open-target-') { @@ -327,6 +327,42 @@ describe('openTargetService', () => { } }) + it('expands a tilde path to the home directory before validation', async () => { + const { service, launched } = createService('darwin') + + await expect(service.openTarget({ targetId: 'finder', path: '~' })) + .resolves.toMatchObject({ ok: true, path: homedir() }) + + expect(launched).toEqual([{ command: 'open', args: [homedir()] }]) + }) + + it('reports missing tilde paths with the expanded home path', async () => { + const { service } = createService('darwin') + const missing = `~/cc-haha-missing-${Date.now()}/report.html` + + const rejection = expect(service.openTarget({ targetId: 'finder', path: missing })).rejects + await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' }) + await rejection.toThrow(join(homedir(), missing.slice(2))) + }) + + it('expands Windows backslash tilde paths on win32', async () => { + const { service } = createService('win32') + const missing = `~\\cc-haha-missing-${Date.now()}\\report.html` + + const rejection = expect(service.openTarget({ targetId: 'explorer', path: missing })).rejects + await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' }) + await rejection.toThrow(homedir() + missing.slice(1)) + }) + + it('keeps backslash tilde names untouched on POSIX platforms', async () => { + const { service } = createService('darwin') + + // On POSIX "~\..." is a regular (if odd) file name, not a tilde path. + const rejection = expect(service.openTarget({ targetId: 'finder', path: '~\\report.html' })).rejects + await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' }) + await rejection.toThrow('~\\report.html') + }) + it('reports launch failures instead of returning success', async () => { const dir = await makeDir() const { service } = createService('darwin', { diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index 45f7dd3b..20a17c60 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -610,8 +610,29 @@ async function resolveLaunchPlan( return null } -async function validateOpenPath(targetPath: string): Promise { - const resolvedPath = resolve(targetPath) +/** + * Expands a leading tilde to the home directory. Models frequently emit + * `~/report.html` (and `~\report.html` on Windows) for generated files; + * without expansion those resolve against the server cwd and always miss. + * `~\` is only a tilde path on Windows — on POSIX the backslash is a valid + * filename character. + */ +function expandTildePath(targetPath: string, platform: OpenTargetPlatform): string { + if ( + targetPath === '~' || + targetPath.startsWith('~/') || + (platform === 'win32' && targetPath.startsWith('~\\')) + ) { + return homedir() + targetPath.slice(1) + } + return targetPath +} + +async function validateOpenPath( + targetPath: string, + platform: OpenTargetPlatform, +): Promise { + const resolvedPath = resolve(expandTildePath(targetPath, platform)) let entry try { entry = await stat(resolvedPath) @@ -972,7 +993,7 @@ export function createOpenTargetService(overrides: Partial = {}) { ) } - const resolvedPath = await validateOpenPath(input.path) + const resolvedPath = await validateOpenPath(input.path, runtime.platform) const launchPlan = await resolveLaunchPlan(definition, runtime, resolvedPath) if (!launchPlan) { throw openTargetError(